Files
technitium-console/hub/zones-sync.test.ts
T
DenozordecandClaude Opus 5.5 392b644faa Hub stage 7: compare and align zones, settings and blocking
Zones gets "All servers" in hub mode: one table of every zone on every
server with its type and serial, marking zones that are on one server
only or whose serials or types differ. A zone can be copied to another
server (export, create, import; Overwrite must be ticked for a zone
that is already there) or made a Secondary there (the source's zone
transfer and notify lists gain the target, then the target zone is
created).

Settings and Blocking can be compared across servers against a baseline
kept in the hub. Host fields stay local, secrets are compared inside
the hub only and never shown or logged, and an apply sends only the
changed shared fields after saving each server's settings to history.
Blocked and Allowed lists are compared as sets; extras are removed only
when asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:20:05 +07:00

531 lines
26 KiB
TypeScript

// @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<string, string>
options: Record<string, Record<string, unknown>>
tsig: string[]
}
const state = new Map<Fake, State>()
function install(f: Fake, init: Partial<State>): 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<string, unknown> = { ...(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<string, any> }[]
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<string, any> }[]
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<string, string>) => 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<string, string>) => 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<string, string>) => hub.call('admin', 'hub/zones/secondary', params)
const preview = (params: Record<string, string>) => 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<string, string>, 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/)
})
})