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() await screen.findByText('Blocker') expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument() unmount() render() 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() 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() 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() 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() 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() 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() 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() 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() 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( {(ref) => ( <>
)} , ) } 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() } }) })