Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
289 lines
15 KiB
TypeScript
289 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { AppsMatrix } from './AppsMatrix'
|
|
import { Apps } from './Apps'
|
|
import { SlotProvider } from '../../app/ChromeSlot'
|
|
import { HubMode } from '../../ui/ClusterNodeSelect'
|
|
import * as api from '../../api/apps'
|
|
import type { AppsMatrixData, InstalledApp } from '../../api/apps'
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
localStorage.clear()
|
|
})
|
|
|
|
/* mdns has Blocker v1.0 with an update; ndns has v2.0; Wild IP is only on mdns. ndns has no admin token. */
|
|
const DATA: AppsMatrixData = {
|
|
servers: [
|
|
{ id: 'mdns', ok: true, writable: true },
|
|
{ id: 'ndns', ok: true, writable: false },
|
|
],
|
|
apps: [
|
|
{
|
|
name: 'Blocker',
|
|
description: '',
|
|
configs: 'same',
|
|
versions: 'different',
|
|
servers: {
|
|
mdns: { installed: true, version: '1.0', updateAvailable: true, updateVersion: '2.0', configStatus: 'read' },
|
|
ndns: { installed: true, version: '2.0', configStatus: 'read' },
|
|
},
|
|
},
|
|
{
|
|
name: 'Wild IP',
|
|
description: '',
|
|
configs: 'single',
|
|
versions: 'single',
|
|
servers: {
|
|
mdns: { installed: true, version: '5.1', configStatus: 'read' },
|
|
ndns: { installed: false, configStatus: 'none' },
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
|
|
|
function mockReads(data: AppsMatrixData = DATA, store = [{ name: 'Blocker', version: '2.0' }]) {
|
|
const matrix = vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
|
vi.spyOn(api, 'listStoreApps').mockResolvedValue({ kind: 'ok', data: { response: { storeApps: store } } } as never)
|
|
return matrix
|
|
}
|
|
|
|
async function openMenu(app: string) {
|
|
await userEvent.click(await screen.findByRole('button', { name: `Actions: ${app}` }))
|
|
}
|
|
|
|
describe('matrix actions', () => {
|
|
it('has no menu for a reader and one per row for a writer', async () => {
|
|
mockReads()
|
|
const { unmount } = render(<AppsMatrix token="t" />)
|
|
await screen.findByText('Blocker')
|
|
expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument()
|
|
unmount()
|
|
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
expect(await screen.findByRole('button', { name: 'Actions: Blocker' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Actions: Wild IP' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('enables only the actions that make sense for the row', async () => {
|
|
mockReads()
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Blocker')
|
|
expect(screen.getByRole('menuitem', { name: 'Update on all' })).toBeEnabled()
|
|
expect(screen.getByRole('button', { name: 'Install on missing servers' })).toBeDisabled()
|
|
expect(screen.getByRole('menuitem', { name: 'Align versions' })).toBeEnabled()
|
|
expect(screen.getByRole('menuitem', { name: 'Uninstall from…' })).toBeEnabled()
|
|
})
|
|
|
|
it('offers Align versions only when the store has the newest installed version', async () => {
|
|
mockReads(DATA, [{ name: 'Blocker', version: '1.5' }])
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Blocker')
|
|
await waitFor(() => expect(api.listStoreApps).toHaveBeenCalled())
|
|
expect(screen.getByRole('button', { name: 'Align versions' })).toBeDisabled()
|
|
})
|
|
|
|
it('offers Install on missing servers for an app that is not everywhere', async () => {
|
|
mockReads()
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Wild IP')
|
|
expect(screen.getByRole('menuitem', { name: 'Install on missing servers' })).toBeEnabled()
|
|
expect(screen.getByRole('button', { name: 'Update on all' })).toBeDisabled()
|
|
})
|
|
|
|
it('shows the picker with a server the hub cannot write to disabled, with the reason', async () => {
|
|
mockReads()
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Wild IP')
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Install on missing servers' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'Install Wild IP' })
|
|
// mdns already has it, ndns has no admin token: neither can be chosen.
|
|
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
|
|
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
|
expect(within(dialog).getByText('Already installed')).toBeInTheDocument()
|
|
expect(within(dialog).getByText('No admin token configured on the hub')).toBeInTheDocument()
|
|
expect(within(dialog).getByRole('button', { name: 'Install' })).toBeDisabled()
|
|
})
|
|
|
|
it('runs the change on the ticked servers, shows each result and refreshes', async () => {
|
|
const matrix = mockReads()
|
|
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }]))
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Blocker')
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
|
|
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
|
|
expect(run).toHaveBeenCalledWith('t', 'update-store', 'Blocker', ['mdns'])
|
|
|
|
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
|
expect(within(results).getByText('Updated from the store.')).toBeInTheDocument()
|
|
expect(within(results).getByText('Done')).toBeInTheDocument()
|
|
expect(within(results).queryByRole('button', { name: 'Retry failed' })).not.toBeInTheDocument()
|
|
const before = matrix.mock.calls.length
|
|
await userEvent.click(within(results).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
|
await waitFor(() => expect(matrix.mock.calls.length).toBeGreaterThan(before))
|
|
})
|
|
|
|
it('retries only the servers that failed', async () => {
|
|
const wide: AppsMatrixData = {
|
|
servers: [
|
|
{ id: 'mdns', ok: true, writable: true },
|
|
{ id: 'ndns', ok: true, writable: true },
|
|
],
|
|
apps: [{ ...DATA.apps[0], servers: { ...DATA.apps[0].servers, ndns: { installed: true, version: '1.0', updateAvailable: true, configStatus: 'read' } } }],
|
|
}
|
|
mockReads(wide)
|
|
const run = vi
|
|
.spyOn(api, 'runHubApps')
|
|
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }, { server: 'ndns', ok: false, message: 'Disk full.' }]))
|
|
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Updated from the store.' }]))
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Blocker')
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
|
|
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['mdns', 'ndns'])
|
|
|
|
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
|
expect(within(results).getByText('Disk full.')).toBeInTheDocument()
|
|
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
|
|
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['ndns'])
|
|
await waitFor(() => expect(within(screen.getByRole('dialog')).queryByText('Disk full.')).not.toBeInTheDocument())
|
|
expect(within(screen.getByRole('dialog')).getAllByText('Done')).toHaveLength(2)
|
|
})
|
|
|
|
it('says the config is saved before an uninstall and starts with nothing ticked', async () => {
|
|
mockReads()
|
|
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Wild IP')
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Uninstall from…' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'Uninstall Wild IP' })
|
|
expect(within(dialog).getByText(/saved to the hub's history/)).toBeInTheDocument()
|
|
expect(within(dialog).getByRole('button', { name: 'Uninstall' })).toBeDisabled()
|
|
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'mdns' }))
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Uninstall' }))
|
|
expect(run).toHaveBeenCalledWith('t', 'uninstall', 'Wild IP', ['mdns'])
|
|
})
|
|
|
|
it('shows a failed call as a failure with Retry', async () => {
|
|
mockReads()
|
|
const run = vi
|
|
.spyOn(api, 'runHubApps')
|
|
.mockResolvedValueOnce({ kind: 'error', message: 'Your role does not allow this.' } as never)
|
|
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true }]))
|
|
render(<AppsMatrix token="t" canWrite />)
|
|
await openMenu('Blocker')
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
|
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Update Blocker' })).getByRole('button', { name: 'Update' }))
|
|
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
|
|
expect(within(results).getByText('Your role does not allow this.')).toBeInTheDocument()
|
|
await userEvent.click(within(results).getByRole('button', { name: 'Retry' }))
|
|
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
|
expect(await within(screen.getByRole('dialog')).findByText('Done')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Apps behind a hub, for writers', () => {
|
|
const INSTALLED: InstalledApp = { name: 'Split Horizon', description: 'd', version: '9.0', updateAvailable: true, updateVersion: '9.1', updateUrl: 'https://x/s.zip', dnsApps: [] }
|
|
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
|
|
|
|
function mount(props: { canWrite: boolean; writable: string[] }) {
|
|
return render(
|
|
<HubMode.Provider value>
|
|
<SlotProvider>
|
|
{(ref) => (
|
|
<>
|
|
<div ref={ref} />
|
|
<Apps token="t" nodes={NODES} clusterInitialised {...props} />
|
|
</>
|
|
)}
|
|
</SlotProvider>
|
|
</HubMode.Provider>,
|
|
)
|
|
}
|
|
|
|
it('shows App Store and Install from file on All servers to writers only', async () => {
|
|
mockReads()
|
|
const { unmount } = mount({ canWrite: false, writable: ['mdns'] })
|
|
await screen.findByText('Blocker')
|
|
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Install from file' })).not.toBeInTheDocument()
|
|
unmount()
|
|
mount({ canWrite: true, writable: ['mdns'] })
|
|
expect(await screen.findByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('opens the hub store and sends an install through the picker', async () => {
|
|
mockReads()
|
|
vi.spyOn(api, 'listStoreApps').mockResolvedValue({
|
|
kind: 'ok',
|
|
data: { response: { storeApps: [{ name: 'Failover', description: 'Backup', version: '8.0', url: 'u', size: '1', installed: false, installedOn: [] }] } },
|
|
} as never)
|
|
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
|
mount({ canWrite: true, writable: ['mdns'] })
|
|
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
|
|
expect(await screen.findByText('Failover')).toBeInTheDocument()
|
|
await userEvent.click(screen.getByRole('button', { name: 'Install…' }))
|
|
const dialog = await screen.findByRole('dialog', { name: 'Install Failover' })
|
|
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Install' }))
|
|
expect(run).toHaveBeenCalledWith('t', 'install-store', 'Failover', ['mdns'])
|
|
})
|
|
|
|
it('uploads a zip to the chosen servers', async () => {
|
|
mockReads()
|
|
const upload = vi.spyOn(api, 'uploadHubApp').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
|
|
mount({ canWrite: true, writable: ['mdns'] })
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Install from file' }))
|
|
const form = await screen.findByRole('dialog', { name: 'Install App' })
|
|
await userEvent.type(within(form).getByLabelText('App Name'), 'Mine')
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'Mine.zip', { type: 'application/zip' })
|
|
await userEvent.upload(within(form).getByLabelText('App Zip File'), file)
|
|
await userEvent.click(within(form).getByRole('button', { name: 'Choose servers' }))
|
|
const picker = await screen.findByRole('dialog', { name: 'Install Mine from file' })
|
|
await userEvent.click(within(picker).getByRole('button', { name: 'Install' }))
|
|
expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns'])
|
|
})
|
|
|
|
it('brings the single-server actions back for a writer, without Config, and sends them to that node', async () => {
|
|
mockReads()
|
|
localStorage.setItem('hubAppsNode', 'mdns')
|
|
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
|
const store = vi.spyOn(api, 'downloadAndUpdate').mockResolvedValue({ kind: 'ok', data: { response: {} } } as never)
|
|
mount({ canWrite: true, writable: ['mdns'] })
|
|
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Config' })).not.toBeInTheDocument()
|
|
await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
|
|
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
|
|
})
|
|
|
|
it('keeps a viewer, and a server without an admin token, read-only', async () => {
|
|
mockReads()
|
|
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
|
|
localStorage.setItem('hubAppsNode', 'ndns')
|
|
const { unmount } = mount({ canWrite: true, writable: ['mdns'] })
|
|
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
|
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
|
|
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
|
}
|
|
unmount()
|
|
|
|
localStorage.setItem('hubAppsNode', 'mdns')
|
|
mount({ canWrite: false, writable: ['mdns'] })
|
|
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
|
|
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
|
|
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
|
|
}
|
|
})
|
|
})
|