// @vitest-environment node /* Stage 7, zones: the merged list, copying a zone and making a Secondary. The servers are fakes that keep a zone table and record every call. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts' import { classifyRow, countRecordLines, secondaryOptions, validNameServer, cleanZone, type ZoneCell } from './zones-sync.ts' import { routeFor } from './routes.ts' interface Z { name: string type: string soaSerial?: number | null disabled?: boolean dnssecStatus?: string internal?: boolean catalog?: string | null } interface State { zones: Z[] files: Record options: Record> tsig: string[] } const state = new Map() function install(f: Fake, init: Partial): void { const s: State = { zones: [], files: {}, options: {}, tsig: [], ...init } state.set(f, s) f.on('zones/list', (c) => { const per = Number(c.query.get('zonesPerPage') ?? 10) const page = Number(c.query.get('pageNumber') ?? 1) return { response: { zones: s.zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(s.zones.length / per)), totalZones: s.zones.length } } }) f.on('zones/export', (c) => { const text = s.files[c.query.get('zone') ?? ''] return text === undefined ? { error: 'No such zone was found.' } : { text } }) f.on('zones/create', (c) => { s.zones.push({ name: c.query.get('zone') ?? c.form.get('zone') ?? '', type: c.query.get('type') ?? c.form.get('type') ?? 'Primary', soaSerial: 1 }) return { response: {} } }) f.on('zones/import', (c) => { s.files[c.query.get('zone') ?? ''] = c.text return { response: {} } }) f.on('zones/options/get', (c) => ({ response: { zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'None', notifyNameServers: [], ...(s.options[c.query.get('zone') ?? ''] ?? {}) }, })) f.on('zones/options/set', (c) => { const zone = c.form.get('zone') ?? '' const next: Record = { ...(s.options[zone] ?? {}) } for (const [k, v] of c.form) { if (k === 'zone') continue next[k] = k.endsWith('ACL') || k.endsWith('Names') || k.endsWith('Servers') ? (v === 'false' ? [] : v.split(',')) : v } s.options[zone] = next return { response: {} } }) f.on('settings/getTsigKeyNames', () => ({ response: { tsigKeyNames: s.tsig } })) } const SOA = '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 5 900 300 604800 900\n@ IN NS ns1\nwww IN A 203.0.113.5\n; a comment\n' let a: Fake let b: Fake let hub: TestHub beforeAll(async () => { a = await fake() b = await fake() hub = await startHub(a, b) }) afterAll(() => { hub.close() a.server.close() b.server.close() }) beforeEach(() => { a.calls = [] b.calls = [] a.down = false b.down = false install(a, { zones: [ { name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' }, { name: 'lan', type: 'Primary', soaSerial: 3 }, { name: 'only-a.test', type: 'Primary', soaSerial: 1 }, { name: 'fwd.test', type: 'Forwarder', disabled: false }, { name: 'signed.test', type: 'Primary', soaSerial: 2, dnssecStatus: 'SignedWithNSEC' }, { name: 'sec.test', type: 'Secondary', soaSerial: 9 }, { name: 'primary.test', type: 'Primary', soaSerial: 10 }, { name: 'localhost', type: 'Primary', soaSerial: 1, internal: true }, ], files: { 'example.net': SOA, 'fwd.test': 'fwd.test. 3600 IN FWD this-server\n', 'signed.test': SOA, 'lan': SOA, 'primary.test': SOA }, tsig: ['transfer.key'], }) install(b, { zones: [ { name: 'example.net', type: 'Primary', soaSerial: 5, dnssecStatus: 'Unsigned' }, { name: 'lan', type: 'Primary', soaSerial: 4 }, { name: 'only-b.test', type: 'Primary', soaSerial: 1 }, { name: 'primary.test', type: 'Secondary', soaSerial: 8 }, { name: 'localhost', type: 'Primary', soaSerial: 1, internal: true }, ], files: {}, tsig: ['transfer.key'], }) }) afterEach(() => { a.down = false b.down = false }) const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST') const present = (type: string, serial: number | null = null): ZoneCell => ({ state: 'present', type, serial }) const absent: ZoneCell = { state: 'absent' } const unknown: ZoneCell = { state: 'unknown' } describe('the status of a row', () => { it('is the same when the zone is alike everywhere', () => { expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 5) }).status).toBe('same') }) it('says serial-differs for two Primary zones with different serials', () => { expect(classifyRow('z', { a: present('Primary', 5), b: present('Primary', 6) }).status).toBe('serial-differs') }) it('says type-differs for types that do not follow one another', () => { expect(classifyRow('z', { a: present('Primary', 5), b: present('Forwarder') }).status).toBe('type-differs') expect(classifyRow('z', { a: present('Stub', 5), b: present('Secondary', 5) }).status).toBe('type-differs') }) it('treats a Primary and its Secondary as the intended setup, and a lagging Secondary as behind, not as a divergence', () => { const same = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 10) }) expect(same).toMatchObject({ status: 'same', note: 'primary-secondary' }) const behind = classifyRow('z', { a: present('Primary', 10), b: present('Secondary', 8) }) expect(behind).toMatchObject({ status: 'same', note: 'secondary-behind' }) expect(classifyRow('z', { a: present('Secondary', 8), b: present('Primary', 10) })).toMatchObject({ status: 'same', note: 'secondary-behind' }) // A Secondary that is AHEAD of its primary is not normal. expect(classifyRow('z', { a: present('Primary', 8), b: present('Secondary', 10) }).status).toBe('serial-differs') }) it('says only-on for a zone one server lacks', () => { expect(classifyRow('z', { a: present('Primary', 1), b: absent })).toMatchObject({ status: 'only-on', onlyOn: 'a' }) }) it('does not call a zone only-on while the other server did not answer', () => { expect(classifyRow('z', { a: present('Primary', 1), b: unknown }).status).toBe('unknown') expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 2), c: unknown }).status).toBe('serial-differs') }) it('names the reachable servers that lack a zone the others have', () => { expect(classifyRow('z', { a: present('Primary', 1), b: present('Primary', 1), c: absent })).toMatchObject({ status: 'missing-on', missingOn: ['c'] }) }) it('ignores serials of types that have none', () => { expect(classifyRow('z', { a: present('Forwarder'), b: present('Forwarder') }).status).toBe('same') }) }) describe('small helpers', () => { it('counts the record lines of a zone file', () => { expect(countRecordLines(SOA)).toBe(3) expect(countRecordLines('')).toBe(0) }) it('checks the zone name', () => { expect(cleanZone('Example.NET.')).toBe('Example.NET') for (const bad of [undefined, '', '*.example.net', 'a b', 'a..b']) expect(cleanZone(bad)).toBeNull() }) it('checks a name server address', () => { for (const ok of ['10.0.0.1', '10.0.0.1:53', 'ns.example.net', 'ns.example.net:5353', '2001:db8::1', '[2001:db8::1]:53']) expect(validNameServer(ok), ok).toBe(true) for (const bad of ['', 'a b', '10.0.0.1,10.0.0.2', 'ns.example.net:abc', '*.example.net', 'x:1:2']) expect(validNameServer(bad), bad).toBe(false) }) }) describe('secondaryOptions', () => { it('widens the modes that cannot hold a list and keeps what is listed', () => { const out = secondaryOptions('z', { zoneTransfer: 'Deny', zoneTransferNetworkACL: ['10.0.0.9'], notify: 'ZoneNameServers', notifyNameServers: ['10.0.0.8'] }, '10.0.0.2', '') expect(out).toEqual({ zone: 'z', zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: '10.0.0.9,10.0.0.2', zoneTransferTsigKeyNames: 'false', notify: 'BothZoneAndSpecifiedNameServers', notifyNameServers: '10.0.0.8,10.0.0.2', }) }) it('maps the other modes and keeps those that already take a list', () => { expect(secondaryOptions('z', { zoneTransfer: 'AllowOnlyZoneNameServers', notify: 'None' }, '10.0.0.2', 'k')).toMatchObject({ zoneTransfer: 'AllowZoneNameServersAndUseSpecifiedNetworkACL', notify: 'SpecifiedNameServers', zoneTransferTsigKeyNames: 'k', }) expect(secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', notify: 'SpecifiedNameServers' }, '10.0.0.2', '')).toMatchObject({ zoneTransfer: 'UseSpecifiedNetworkACL', notify: 'SpecifiedNameServers', }) }) it('is null when the address and key are already there', () => { expect( secondaryOptions('z', { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.0.0.2'], zoneTransferTsigKeyNames: ['k'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.0.0.2'] }, '10.0.0.2', 'k'), ).toBeNull() }) }) describe('routes', () => { it('lists the zone endpoints with their kinds and rights', () => { expect(routeFor('hub/zones/matrix')).toEqual({ kind: 'read', capability: 'read' }) expect(routeFor('hub/zones/copy/preview')).toEqual({ kind: 'read', capability: 'admin' }) expect(routeFor('hub/zones/copy')).toEqual({ kind: 'write-all', capability: 'admin' }) expect(routeFor('hub/zones/secondary/preview')).toEqual({ kind: 'read', capability: 'admin' }) expect(routeFor('hub/zones/secondary')).toEqual({ kind: 'write-all', capability: 'admin' }) }) it('refuses the admin endpoints to every other role, without touching a server', async () => { const params = { source: 'a', zone: 'example.net', targets: 'b', target: 'b', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' } for (const role of ROLES.filter((r) => r !== 'admin')) { for (const p of ['hub/zones/copy/preview', 'hub/zones/copy', 'hub/zones/secondary/preview', 'hub/zones/secondary']) { const r = await hub.call(role, p, params) expect(r.code, `${role} ${p}`).toBe(403) } } expect(a.calls).toHaveLength(0) expect(b.calls).toHaveLength(0) }) it('lets a viewer read the merged list', async () => { expect((await hub.call('viewer', 'hub/zones/matrix', {}, 'GET')).body.status).toBe('ok') }) }) describe('hub/zones/matrix', () => { it('merges the zones of both servers, one row per name, skipping the internal ones', async () => { const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') const rows = r.body.response.zones as { name: string; status: string; onlyOn?: string; note?: string; cells: Record }[] const by = Object.fromEntries(rows.map((z) => [z.name, z])) expect(Object.keys(by).sort()).toEqual(['example.net', 'fwd.test', 'lan', 'only-a.test', 'only-b.test', 'primary.test', 'sec.test', 'signed.test']) expect(by['example.net'].status).toBe('same') expect(by.lan.status).toBe('serial-differs') expect(by['only-a.test']).toMatchObject({ status: 'only-on', onlyOn: 'a' }) expect(by['only-b.test']).toMatchObject({ status: 'only-on', onlyOn: 'b' }) expect(by['primary.test']).toMatchObject({ status: 'same', note: 'secondary-behind' }) expect(by['example.net'].cells.a).toMatchObject({ state: 'present', type: 'Primary', serial: 5, dnssec: 'Unsigned' }) expect(by.lan.cells.b.serial).toBe(4) expect(r.body.response.servers).toEqual([ { id: 'a', ok: true, writable: true, host: '127.0.0.1' }, { id: 'b', ok: true, writable: true, host: '127.0.0.1' }, ]) expect(r.body.hubWarnings).toEqual([]) }) it('asks for every page', async () => { const s = state.get(a)! s.zones = Array.from({ length: 1200 }, (_, i) => ({ name: `z${String(i).padStart(4, '0')}.test`, type: 'Primary', soaSerial: 1 })) const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') expect(r.body.response.zones.filter((z: { name: string }) => z.name.startsWith('z'))).toHaveLength(1200) expect(a.calls.filter((c) => c.path === 'zones/list').length).toBeGreaterThanOrEqual(3) }) it('marks the cells of a server that did not answer as unknown, warns, and does not call the zone only-on', async () => { b.down = true const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') expect(r.body.status).toBe('ok') expect(r.body.hubWarnings).toHaveLength(1) expect(r.body.hubWarnings[0]).toMatch(/^b:/) const rows = r.body.response.zones as { name: string; status: string; cells: Record }[] const lan = rows.find((z) => z.name === 'lan')! expect(lan.cells.b).toEqual({ state: 'unknown' }) expect(lan.status).toBe('unknown') expect(rows.some((z) => z.status === 'only-on')).toBe(false) expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b').ok).toBe(false) }) it('fails when no server answers', async () => { a.down = true b.down = true const r = await hub.call('admin', 'hub/zones/matrix', {}, 'GET') expect(r.body.status).toBe('error') }) }) describe('hub/zones/copy/preview', () => { const preview = (params: Record) => hub.call('admin', 'hub/zones/copy/preview', params) it('says what the source has and what each target holds', async () => { const r = await preview({ source: 'a', zone: 'example.net', targets: 'b' }) expect(r.body.response).toMatchObject({ zone: 'example.net', source: { server: 'a', type: 'Primary', serial: 5, records: 3 }, targets: [{ server: 'b', ok: true, exists: true, type: 'Primary', serial: 5, needsOverwrite: true }], }) // only-a.test has no file on the fake, so the export fails and the preview says so. expect((await preview({ source: 'a', zone: 'only-a.test', targets: 'b' })).body.status).toBe('error') }) it('shows a target that lacks the zone as free to create', async () => { state.get(a)!.files['only-a.test'] = SOA const r = await preview({ source: 'a', zone: 'only-a.test', targets: 'b' }) expect(r.body.response.targets[0]).toMatchObject({ server: 'b', ok: true, exists: false, needsOverwrite: false }) }) it('refuses a Secondary, a signed zone, a missing zone and the source itself, each with a reason', async () => { expect((await preview({ source: 'a', zone: 'sec.test', targets: 'b' })).body.errorMessage).toMatch(/Only Primary and Forwarder/) expect((await preview({ source: 'a', zone: 'signed.test', targets: 'b' })).body.errorMessage).toMatch(/DNSSEC-signed/) expect((await preview({ source: 'a', zone: 'nope.test', targets: 'b' })).body.errorMessage).toMatch(/no zone nope\.test/) expect((await preview({ source: 'a', zone: 'example.net', targets: 'a' })).body.errorMessage).toMatch(/server it comes from/) expect((await preview({ source: 'x', zone: 'example.net', targets: 'b' })).body.errorMessage).toMatch(/Unknown source server/) expect((await preview({ source: 'a', zone: 'example.net', targets: '' })).body.errorMessage).toMatch(/at least one server/) expect((await preview({ source: 'a', zone: '*.net', targets: 'b' })).body.errorMessage).toMatch(/valid zone name/) }) it('refuses a target that holds the zone as another type', async () => { state.get(b)!.zones.push({ name: 'fwd.test', type: 'Primary', soaSerial: 1 }) const r = await preview({ source: 'a', zone: 'fwd.test', targets: 'b' }) expect(r.body.response.targets[0]).toMatchObject({ ok: false, exists: true, type: 'Primary' }) expect(r.body.response.targets[0].error).toMatch(/cannot replace it/) }) it('does not write anything and reads the export with the read token', async () => { await preview({ source: 'a', zone: 'example.net', targets: 'b' }) expect(writes(a)).toHaveLength(0) expect(writes(b)).toHaveLength(0) expect(a.calls.find((c) => c.path === 'zones/export')!.token).toBe('read-a') }) }) describe('hub/zones/copy', () => { const copy = (params: Record) => hub.call('admin', 'hub/zones/copy', params) it('creates the zone on a target that lacks it and imports the text, with the admin token', async () => { state.get(a)!.files['only-a.test'] = SOA const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) expect(r.body.response.results).toEqual([{ server: 'b', ok: true, message: 'Zone created and filled.' }]) const [create, imp] = writes(b) expect(create.path).toBe('zones/create') expect(create.token).toBe('admin-b') expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'only-a.test', type: 'Primary' }) expect(imp.path).toBe('zones/import') expect(imp.token).toBe('admin-b') expect(Object.fromEntries(imp.query)).toMatchObject({ zone: 'only-a.test', overwrite: 'true', overwriteZone: 'true', overwriteSoaSerial: 'true' }) expect(imp.text).toBe(SOA) expect(writes(a)).toHaveLength(0) }) it('creates a Forwarder empty and fills it by the import', async () => { const r = await copy({ source: 'a', zone: 'fwd.test', targets: 'b' }) expect(r.body.response.results[0].ok).toBe(true) const create = writes(b)[0] expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'fwd.test', type: 'Forwarder', initializeForwarder: 'false' }) }) it('refuses to replace a zone that exists unless Overwrite was ticked for that server', async () => { const refused = await copy({ source: 'a', zone: 'example.net', targets: 'b' }) expect(refused.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) expect(refused.body.response.results[0].message).toMatch(/Tick Overwrite/) expect(writes(b)).toHaveLength(0) const other = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'a' }) expect(other.body.response.results[0].ok).toBe(false) expect(writes(b)).toHaveLength(0) const done = await copy({ source: 'a', zone: 'example.net', targets: 'b', overwrite: 'b' }) expect(done.body.response.results[0]).toEqual({ server: 'b', ok: true, message: 'Records replaced.' }) expect(writes(b).map((c) => c.path)).toEqual(['zones/import']) }) it('refuses a Secondary source and a copy to the source, and writes nothing', async () => { expect((await copy({ source: 'a', zone: 'sec.test', targets: 'b' })).code).toBe(400) expect((await copy({ source: 'a', zone: 'example.net', targets: 'a' })).code).toBe(400) expect(writes(a)).toHaveLength(0) expect(writes(b)).toHaveLength(0) }) it('reports a target that fails and still finishes the others', async () => { state.get(a)!.files['only-a.test'] = SOA b.on('zones/create', () => ({ error: 'Access was denied.' })) const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) expect(r.body.response.results[0].message).toMatch(/could not be created: Access was denied/) }) it('says an empty zone was left when the import fails after the create', async () => { state.get(a)!.files['only-a.test'] = SOA b.on('zones/import', () => ({ error: 'Bad record.' })) const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) expect(r.body.response.results[0].ok).toBe(false) expect(r.body.response.results[0].message).toMatch(/created but the import failed \(Bad record\.\); an empty zone is left on b/) }) it('reports an unreachable target without failing the request', async () => { state.get(a)!.files['only-a.test'] = SOA b.down = true const r = await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) expect(r.body.status).toBe('ok') expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false }) }) it('writes one audit line per target with the zone, the source, the target and the outcome, and no zone content', async () => { state.get(a)!.files['only-a.test'] = SOA await copy({ source: 'a', zone: 'only-a.test', targets: 'b' }) const line = (await hub.audit()).find((e) => e.action === 'zones.copy')! expect(line).toMatchObject({ target: 'only-a.test', server: 'b', outcome: 'ok' }) expect(line.detail).toMatch(/from a/) expect(JSON.stringify(line)).not.toContain('203.0.113.5') }) }) describe('hub/zones/secondary', () => { const input = { source: 'a', target: 'b', zone: 'example.net', primaryAddress: '10.0.0.1', targetAddress: '10.0.0.2' } const secondary = (params: Record) => hub.call('admin', 'hub/zones/secondary', params) const preview = (params: Record) => hub.call('admin', 'hub/zones/secondary/preview', params) beforeEach(() => { // The target does not have the zone yet. state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net') }) it('previews the two steps and writes nothing', async () => { const r = await preview(input) expect(r.body.response.steps).toHaveLength(2) expect(r.body.response.steps[0]).toMatchObject({ server: 'a', role: 'source', changes: true }) expect(r.body.response.steps[1]).toMatchObject({ server: 'b', role: 'target', changes: true }) expect(writes(a)).toHaveLength(0) expect(writes(b)).toHaveLength(0) }) it('adds the target to transfer and notify on the source, keeping what is there, then creates the Secondary', async () => { state.get(a)!.options['example.net'] = { zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: ['10.9.9.9'], notify: 'SpecifiedNameServers', notifyNameServers: ['10.9.9.8'] } const r = await secondary({ ...input, tsigKeyName: 'transfer.key' }) expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]]) const set = writes(a)[0] expect(set.path).toBe('zones/options/set') expect(set.token).toBe('admin-a') expect(Object.fromEntries(set.form)).toEqual({ zone: 'example.net', zoneTransfer: 'UseSpecifiedNetworkACL', zoneTransferNetworkACL: '10.9.9.9,10.0.0.2', zoneTransferTsigKeyNames: 'transfer.key', notify: 'SpecifiedNameServers', notifyNameServers: '10.9.9.8,10.0.0.2', }) const create = writes(b)[0] expect(create.path).toBe('zones/create') expect(create.token).toBe('admin-b') expect(Object.fromEntries(new URLSearchParams([...create.form, ...create.query]))).toMatchObject({ zone: 'example.net', type: 'Secondary', primaryNameServerAddresses: '10.0.0.1', zoneTransferProtocol: 'Tcp', tsigKeyName: 'transfer.key', }) // The source went first. expect(a.calls.findIndex((c) => c.path === 'zones/options/set')).toBeGreaterThan(-1) }) it('can run again: an address already allowed is not written twice and the source is left alone', async () => { await secondary(input) state.get(b)!.zones = state.get(b)!.zones.filter((z) => z.name !== 'example.net') a.calls = [] const again = await secondary(input) expect(again.body.response.results[0]).toMatchObject({ server: 'a', ok: true }) expect(again.body.response.results[0].message).toMatch(/already allow/) expect(writes(a)).toHaveLength(0) expect(writes(b).filter((c) => c.path === 'zones/create')).toHaveLength(2) }) it('reports a failing target and says the source change was left in place', async () => { b.on('zones/create', () => ({ error: 'The primary is not reachable.' })) const r = await secondary(input) const [source, target] = r.body.response.results expect(source).toMatchObject({ server: 'a', ok: true }) expect(target).toMatchObject({ server: 'b', ok: false }) expect(target.message).toMatch(/could not be created: The primary is not reachable\./) expect(target.message).toMatch(/left in place/) }) it('does not touch the target when the source step fails', async () => { a.on('zones/options/set', () => ({ error: 'Access was denied.' })) const r = await secondary(input) expect(r.body.response.results[0]).toMatchObject({ server: 'a', ok: false }) expect(r.body.response.results[1]).toMatchObject({ server: 'b', ok: false }) expect(writes(b)).toHaveLength(0) }) it('refuses what cannot work, with a reason and no write', async () => { const refused = async (patch: Record, text: RegExp) => { const r = await secondary({ ...input, ...patch }) expect(r.code).toBe(400) expect(r.body.errorMessage).toMatch(text) } await refused({ zone: 'sec.test' }, /Only a Primary zone/) await refused({ zone: 'nope.test' }, /no zone nope\.test/) await refused({ target: 'a' }, /two different servers/) await refused({ primaryAddress: '' }, /primary's address/) await refused({ targetAddress: 'ns.example.net' }, /IP address/) await refused({ tsigKeyName: 'missing.key' }, /does not exist on a/) state.get(b)!.zones.push({ name: 'example.net', type: 'Primary' }) await refused({}, /already has example\.net/) expect(writes(a)).toHaveLength(0) expect(writes(b)).toHaveLength(0) }) it('needs the TSIG key on both servers', async () => { state.get(b)!.tsig = [] const r = await secondary({ ...input, tsigKeyName: 'transfer.key' }) expect(r.body.errorMessage).toMatch(/does not exist on b/) expect(writes(a)).toHaveLength(0) }) it('audits both steps', async () => { await secondary(input) // Newest first: the two lines of this call are the first two of their action. const lines = (await hub.audit()).filter((e) => e.action === 'zones.secondary').slice(0, 2) expect(lines.map((l) => [l.server, l.outcome]).sort()).toEqual([['a', 'ok'], ['b', 'ok']]) expect(lines[0].target).toBe('example.net') expect(lines[0].detail).toMatch(/from a to b/) }) })