Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
282 lines
15 KiB
TypeScript
282 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 { AppConfigSync } from './AppConfigSync'
|
|
import { AppsMatrix } from './AppsMatrix'
|
|
import * as api from '../../api/apps'
|
|
import type { ConfigCompare, ServerConfigView } from '../../api/apps'
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
const A = JSON.stringify({ ttl: 30, db: '/a.db' }, null, 2)
|
|
const B = JSON.stringify({ ttl: 60, db: '/b.db' }, null, 2)
|
|
|
|
function view(id: string, over: Partial<ServerConfigView> = {}): ServerConfigView {
|
|
return { id, ok: true, installed: true, writable: true, version: '1.0', text: A, hash: 'h-' + id, warnings: [], inSync: null, vsOthers: {}, ...over }
|
|
}
|
|
|
|
/* mdns is the baseline; ndns differs in ttl and in the local db path. */
|
|
const WITH_BASELINE: ConfigCompare = {
|
|
name: 'Blocker',
|
|
baseline: { app: 'Blocker', text: A, importedFrom: 'mdns', updatedAt: '2026-10-31T08:00:00Z', updatedBy: 'alice' },
|
|
overrides: { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } },
|
|
servers: [
|
|
view('mdns', { inSync: true, vsBaseline: { kind: 'json', changes: [] } }),
|
|
view('ndns', {
|
|
text: B,
|
|
inSync: false,
|
|
vsBaseline: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }, { path: 'db', type: 'changed', a: '/x.db', b: '/b.db' }] },
|
|
}),
|
|
],
|
|
}
|
|
|
|
const NO_BASELINE: ConfigCompare = {
|
|
name: 'Blocker',
|
|
baseline: null,
|
|
overrides: { paths: [], values: {} },
|
|
servers: [
|
|
view('mdns', { vsOthers: { ndns: { kind: 'json', changes: [{ path: 'ttl', type: 'changed', a: 60, b: 30 }] } } }),
|
|
view('ndns', { text: B }),
|
|
],
|
|
}
|
|
|
|
function mockCompare(data: ConfigCompare) {
|
|
return vi.spyOn(api, 'getConfigCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
|
|
}
|
|
|
|
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
|
|
|
|
function mount(canWrite: boolean) {
|
|
const onChanged = vi.fn()
|
|
render(<AppConfigSync token="t" name="Blocker" canWrite={canWrite} onClose={() => {}} onChanged={onChanged} />)
|
|
return { onChanged }
|
|
}
|
|
|
|
describe('Compare', () => {
|
|
it('shows a skeleton while loading, then the badges and the fields that differ', async () => {
|
|
mockCompare(WITH_BASELINE)
|
|
mount(false)
|
|
expect(screen.getByRole('status', { name: 'Loading the configs' })).toBeInTheDocument()
|
|
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
|
expect(screen.getByText('Differs')).toBeInTheDocument()
|
|
const dialog = screen.getByRole('dialog', { name: 'App config: Blocker' })
|
|
expect(within(dialog).getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
|
|
expect(within(dialog).getByText('ttl')).toBeInTheDocument()
|
|
expect(within(dialog).getByText('60')).toBeInTheDocument()
|
|
expect(within(dialog).getByText('expected 30')).toBeInTheDocument()
|
|
// The local field is marked.
|
|
const dbRow = within(dialog).getByText('db').closest('tr') as HTMLElement
|
|
expect(within(dbRow).getByText('Local')).toBeInTheDocument()
|
|
})
|
|
|
|
it('compares the servers to each other when there is no baseline', async () => {
|
|
mockCompare(NO_BASELINE)
|
|
mount(false)
|
|
expect(await screen.findByText('No baseline yet')).toBeInTheDocument()
|
|
expect(screen.getAllByText('No baseline').length).toBeGreaterThan(0)
|
|
expect(screen.getByText('ttl')).toBeInTheDocument()
|
|
expect(screen.getByText('30')).toBeInTheDocument()
|
|
expect(screen.getByText('60')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows lines for a text config', async () => {
|
|
mockCompare({
|
|
...WITH_BASELINE,
|
|
servers: [
|
|
view('mdns', { inSync: true, vsBaseline: { kind: 'text', lines: [], truncated: false, changed: 0 } }),
|
|
view('ndns', { inSync: false, vsBaseline: { kind: 'text', lines: [{ op: 'same', text: 'a = 1' }, { op: 'remove', text: 'b = 2' }, { op: 'add', text: 'b = 3' }], truncated: false, changed: 2 } }),
|
|
],
|
|
})
|
|
mount(false)
|
|
const lines = await screen.findByLabelText('Line differences')
|
|
expect(within(lines).getByText(/b = 3/)).toHaveAttribute('data-op', 'add')
|
|
expect(within(lines).getByText(/b = 2/)).toHaveAttribute('data-op', 'remove')
|
|
})
|
|
|
|
it('shows an error with Retry', async () => {
|
|
const spy = vi.spyOn(api, 'getConfigCompare').mockResolvedValueOnce({ kind: 'error', message: 'No server answered.' } as never)
|
|
mount(false)
|
|
expect(await screen.findByText(/Could not read the configs/)).toHaveTextContent('No server answered.')
|
|
spy.mockResolvedValue({ kind: 'ok', data: { response: WITH_BASELINE } } as never)
|
|
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
expect(await screen.findByText('In sync')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('role gating', () => {
|
|
it('gives a reader no editing controls', async () => {
|
|
mockCompare(WITH_BASELINE)
|
|
mount(false)
|
|
await screen.findByText('In sync')
|
|
expect(screen.queryByRole('button', { name: 'Apply…' })).not.toBeInTheDocument()
|
|
await userEvent.click(screen.getByRole('tab', { name: 'Baseline' }))
|
|
expect(screen.getByLabelText('Baseline text')).toHaveAttribute('readonly')
|
|
expect(screen.queryByRole('button', { name: 'Save baseline' })).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
|
await userEvent.click(screen.getByRole('tab', { name: 'Local fields' }))
|
|
expect(screen.getByLabelText('db on mdns')).toHaveAttribute('readonly')
|
|
expect(screen.queryByRole('button', { name: 'Save local fields' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('tells a reader who sees no baseline to ask, without import buttons', async () => {
|
|
mockCompare(NO_BASELINE)
|
|
mount(false)
|
|
await screen.findByText('No baseline yet')
|
|
expect(screen.queryByRole('button', { name: /^Import from/ })).not.toBeInTheDocument()
|
|
expect(screen.getByText(/apps administrator/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('offers a writer the import from each server when there is no baseline', async () => {
|
|
const spy = mockCompare(NO_BASELINE)
|
|
const imp = vi.spyOn(api, 'importBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: WITH_BASELINE.baseline } } } as never)
|
|
const { onChanged } = mount(true)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Import from mdns' }))
|
|
expect(imp).toHaveBeenCalledWith('t', 'Blocker', 'mdns')
|
|
await waitFor(() => expect(spy.mock.calls.length).toBeGreaterThan(1))
|
|
expect(onChanged).toHaveBeenCalled()
|
|
expect(await screen.findByText('Baseline imported from mdns.')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Baseline and Local fields', () => {
|
|
it('shows who updated the baseline and a validation error from the hub inline', async () => {
|
|
mockCompare(WITH_BASELINE)
|
|
const save = vi.spyOn(api, 'saveBaseline').mockResolvedValue({ kind: 'error', message: 'The baseline is not valid JSON: Unexpected token at position 4' } as never)
|
|
mount(true)
|
|
await userEvent.click(await screen.findByRole('tab', { name: 'Baseline' }))
|
|
expect(screen.getByText(/Imported from mdns\. Updated by alice/)).toBeInTheDocument()
|
|
const box = screen.getByLabelText('Baseline text')
|
|
await userEvent.clear(box)
|
|
await userEvent.type(box, 'x')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Save baseline' }))
|
|
expect(save).toHaveBeenCalledWith('t', 'Blocker', 'x')
|
|
expect(await screen.findByText(/not valid JSON: Unexpected token at position 4/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('adds a local field, fills the values from the servers and saves them', async () => {
|
|
mockCompare({ ...WITH_BASELINE, overrides: { paths: [], values: {} } })
|
|
const current = vi.spyOn(api, 'getCurrentLocalValues').mockResolvedValue({ kind: 'ok', data: { response: { values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } } } } as never)
|
|
const save = vi.spyOn(api, 'saveOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: WITH_BASELINE.overrides } } } as never)
|
|
mount(true)
|
|
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
|
expect(screen.getByText('No local fields yet.')).toBeInTheDocument()
|
|
await userEvent.type(screen.getByLabelText('New local field'), 'db')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Add field' }))
|
|
await userEvent.click(screen.getByRole('button', { name: 'Fill from the current server configs' }))
|
|
expect(current).toHaveBeenCalledWith('t', 'Blocker', ['db'])
|
|
expect(await screen.findByDisplayValue('"/b.db"')).toBeInTheDocument()
|
|
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
|
expect(save).toHaveBeenCalledWith('t', 'Blocker', { paths: ['db'], values: { mdns: { db: '/a.db' }, ndns: { db: '/b.db' } } })
|
|
})
|
|
|
|
it('refuses a value that is not JSON, without calling the hub', async () => {
|
|
mockCompare(WITH_BASELINE)
|
|
const save = vi.spyOn(api, 'saveOverrides')
|
|
mount(true)
|
|
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
|
|
const box = screen.getByLabelText('db on mdns')
|
|
await userEvent.clear(box)
|
|
await userEvent.type(box, 'no quotes')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
|
|
expect(await screen.findByText(/db on mdns is not valid JSON/)).toBeInTheDocument()
|
|
expect(save).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('Apply', () => {
|
|
it('picks servers, previews, applies with the preview hashes, and retries only what failed', async () => {
|
|
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: true }] })
|
|
const preview = vi.spyOn(api, 'previewConfig').mockResolvedValue({
|
|
kind: 'ok',
|
|
data: {
|
|
response: {
|
|
targets: [
|
|
{ server: 'ndns', ok: true, writable: true, current: B, currentHash: 'hash-ndns', effective: A, unchanged: false, warnings: ['Version 0.9 here, 1.0 on mdns: versions differ, align versions first.'], changes: WITH_BASELINE.servers[1].vsBaseline },
|
|
],
|
|
},
|
|
},
|
|
} as never)
|
|
const apply = vi
|
|
.spyOn(api, 'applyConfig')
|
|
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: false, message: 'The config is invalid.' }]))
|
|
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Applied and verified.' }]))
|
|
const { onChanged } = mount(true)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
|
|
|
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
|
// Only the server that differs is ticked.
|
|
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
|
|
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
|
|
await userEvent.click(within(picker).getByRole('button', { name: 'Preview' }))
|
|
expect(preview).toHaveBeenCalledWith('t', 'Blocker', ['ndns'])
|
|
|
|
const review = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: review' })
|
|
expect(within(review).getByText('Will change')).toBeInTheDocument()
|
|
expect(within(review).getByText(/align versions first/)).toBeInTheDocument()
|
|
expect(within(review).getByText('ttl')).toBeInTheDocument()
|
|
expect(apply).not.toHaveBeenCalled()
|
|
await userEvent.click(within(review).getByRole('button', { name: 'Apply' }))
|
|
expect(apply).toHaveBeenLastCalledWith('t', 'Blocker', ['ndns'], { ndns: 'hash-ndns' })
|
|
|
|
const results = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker: results' })
|
|
expect(within(results).getByText('The config is invalid.')).toBeInTheDocument()
|
|
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
|
|
expect(apply).toHaveBeenCalledTimes(2)
|
|
await waitFor(() => expect(within(screen.getByRole('dialog')).getByText('Applied and verified.')).toBeInTheDocument())
|
|
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
|
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
|
})
|
|
|
|
it('cannot apply without a baseline, and lists a server without a token as disabled', async () => {
|
|
mockCompare(NO_BASELINE)
|
|
mount(true)
|
|
await screen.findByText('No baseline yet')
|
|
expect(screen.getByRole('button', { name: 'Apply…' })).toBeDisabled()
|
|
})
|
|
|
|
it('disables a server the hub cannot write to in the picker', async () => {
|
|
mockCompare({ ...WITH_BASELINE, servers: [WITH_BASELINE.servers[0], { ...WITH_BASELINE.servers[1], writable: false }] })
|
|
mount(true)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
|
const picker = await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })
|
|
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
|
expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows a review failure from the hub', async () => {
|
|
mockCompare(WITH_BASELINE)
|
|
vi.spyOn(api, 'previewConfig').mockResolvedValue({ kind: 'error', message: 'This app has no baseline yet.' } as never)
|
|
mount(true)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Apply…' }))
|
|
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Apply the baseline of Blocker' })).getByRole('button', { name: 'Preview' }))
|
|
expect(await screen.findByText('This app has no baseline yet.')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('from the matrix', () => {
|
|
const DATA: api.AppsMatrixData = {
|
|
servers: [{ id: 'mdns', ok: true, writable: true }, { id: 'ndns', ok: true, writable: true }],
|
|
apps: [
|
|
{
|
|
name: 'Blocker', description: '', configs: 'different', diffCount: 2, versions: 'same',
|
|
servers: { mdns: { installed: true, version: '1.0', configStatus: 'read' }, ndns: { installed: true, version: '1.0', configStatus: 'read' } },
|
|
},
|
|
],
|
|
}
|
|
|
|
it('opens the config view from the row menu for a reader and from the Different badge', async () => {
|
|
vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
|
|
const compare = mockCompare(WITH_BASELINE)
|
|
render(<AppsMatrix token="t" />)
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Actions: Blocker' }))
|
|
await userEvent.click(screen.getByRole('menuitem', { name: 'Config…' }))
|
|
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
|
expect(compare).toHaveBeenCalledWith('t', 'Blocker')
|
|
await userEvent.click(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Compare the configs of Blocker' }))
|
|
expect(await screen.findByRole('dialog', { name: 'App config: Blocker' })).toBeInTheDocument()
|
|
})
|
|
})
|