Files
technitium-console/hub/settings-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 <noreply@anthropic.com>
2026-11-01 01:20:05 +07:00

631 lines
30 KiB
TypeScript

// @vitest-environment node
/*
Stage 7, settings and blocking: the pure rules (local, ignored and secret fields, the encoding for
settings/set) and the endpoints against fake servers that keep their settings and lists.
*/
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
import {
SECRET_PLACEHOLDER,
changedFields,
compareSettings,
diffSets,
isBuiltInLocal,
maskSettings,
normalize,
parseDomains,
prepare,
restoreSecrets,
sameValue,
toParams,
} from './settings-sync.ts'
import { routeFor } from './routes.ts'
const TSIG = 'TSIG-SECRET-9f3a'
const PROXY = 'PROXY-PASS-77c1'
const CERT = 'CERT-PASS-4b2d'
function settingsOf(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
version: '15.5',
uptimestamp: '2026-01-01T00:00:00Z',
clusterInitialized: false,
dnsServerDomain: 'a.example.net',
dnsServerLocalEndPoints: ['0.0.0.0:53'],
dnsServerIPv4SourceAddresses: null,
webServiceHttpPort: 5380,
webServiceTlsCertificatePassword: CERT,
dnsTlsCertificatePath: '/etc/a.pfx',
dnsTlsCertificatePassword: CERT,
logFolder: 'logs-a',
defaultRecordTtl: 3600,
dnssecValidation: true,
recursion: 'AllowOnlyForPrivateNetworks',
forwarders: ['1.1.1.1', '9.9.9.9'],
qpmPrefixLimitsIPv4: [{ prefix: 32, udpLimit: 600, tcpLimit: 600 }],
tsigKeys: [{ keyName: 'transfer.key', sharedSecret: TSIG, algorithmName: 'hmac-sha256' }],
proxy: null,
enableBlocking: true,
temporaryDisableBlockingTill: null,
blockingType: 'NxDomain',
blockingAnswerTtl: 30,
blockListUrls: ['https://a.example/hosts', '# Privacy'],
blockListNextUpdatedOn: '2026-02-01T00:00:00Z',
...over,
}
}
interface Box {
settings: Record<string, unknown>
blocked: string[]
allowed: string[]
/* The settings a read answers with after a write, to pretend the server ignored the change. */
ignoreWrites: boolean
}
const boxes = new Map<Fake, Box>()
function decode(old: unknown, value: string): unknown {
if (typeof old === 'boolean') return value === 'true'
if (typeof old === 'number') return Number(value)
if (Array.isArray(old)) return value === 'false' ? [] : value.split(',')
return value
}
function install(f: Fake, box: Box): void {
boxes.set(f, box)
f.on('settings/get', () => ({ response: box.settings }))
f.on('settings/set', (c: Call) => {
if (box.ignoreWrites) return { response: box.settings }
for (const [k, v] of c.form) {
if (k === 'tsigKeys') {
box.settings.tsigKeys = v === 'false' ? [] : (() => {
const cells = v.split('|')
const keys = []
for (let i = 0; i < cells.length; i += 3) keys.push({ keyName: cells[i], sharedSecret: cells[i + 1], algorithmName: cells[i + 2] })
return keys
})()
} else if (k === 'qpmPrefixLimitsIPv4') {
const cells = v.split('|')
box.settings[k] = v === 'false' ? [] : [{ prefix: Number(cells[0]), udpLimit: Number(cells[1]), tcpLimit: Number(cells[2]) }]
} else if (k === 'proxyType') {
box.settings.proxy = v === 'none' ? null : { type: v, address: c.form.get('proxyAddress'), port: Number(c.form.get('proxyPort')), username: c.form.get('proxyUsername'), password: c.form.get('proxyPassword'), bypass: [] }
} else if (!k.startsWith('proxy') && k in box.settings) box.settings[k] = decode(box.settings[k], v)
}
return { response: box.settings }
})
for (const list of ['blocked', 'allowed'] as const) {
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
f.on(`${list}/add`, (c) => {
box[list].push(c.form.get('domain') ?? '')
return { response: {} }
})
f.on(`${list}/delete`, (c) => {
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
return { response: {} }
})
}
}
let a: Fake
let b: Fake
let hub: TestHub
const boxB = (): Box => boxes.get(b)!
const writes = (f: Fake): Call[] => f.calls.filter((c) => c.method === 'POST')
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, { settings: settingsOf(), blocked: ['ads.example', 'both.example'], allowed: ['ok.example'], ignoreWrites: false })
install(
b,
{
settings: settingsOf({
dnsServerDomain: 'b.example.net',
logFolder: 'logs-b',
webServiceHttpPort: 5381,
uptimestamp: '2026-03-03T00:00:00Z',
defaultRecordTtl: 7200,
forwarders: ['8.8.8.8'],
blockingType: 'CustomAddress',
}),
blocked: ['both.example', 'only-b.example'],
allowed: ['ok.example', 'extra-allowed.example'],
ignoreWrites: false,
},
)
// A baseline and its history from an earlier test must not leak into the next one.
for (const dir of ['settings', 'blocking']) rmSync(path.join(hub.dataDir, dir), { recursive: true, force: true })
})
describe('which fields count', () => {
it('treats the host identity, certificates, paths and every webService field as local', () => {
for (const n of ['dnsServerDomain', 'dnsServerLocalEndPoints', 'dnsServerIPv4SourceAddresses', 'dnsServerIPv6SourceAddresses', 'webServiceHttpPort', 'webServiceTlsCertificatePath', 'dnsTlsCertificatePath', 'dnsTlsCertificatePassword', 'logFolder', 'ssoEnabled', 'ldapServer']) {
expect(isBuiltInLocal(n), n).toBe(true)
}
for (const n of ['forwarders', 'defaultRecordTtl', 'blockListUrls', 'tsigKeys']) expect(isBuiltInLocal(n), n).toBe(false)
})
it('drops the read-only and runtime fields', () => {
const n = normalize(settingsOf())
for (const gone of ['version', 'uptimestamp', 'clusterInitialized', 'temporaryDisableBlockingTill', 'blockListNextUpdatedOn']) expect(n).not.toHaveProperty(gone)
expect(n).toHaveProperty('forwarders')
})
it('leaves the blocking switch alone while blocking is paused', () => {
const p = prepare(settingsOf({ enableBlocking: false, temporaryDisableBlockingTill: '2026-12-01T00:00:00Z' }))
expect(p.paused).toBe(true)
expect(p.settings).not.toHaveProperty('enableBlocking')
})
it('compares by meaning: absent, null, empty text and an empty list are the same, key order does not count', () => {
expect(sameValue('x', null, '')).toBe(true)
expect(sameValue('x', [], null)).toBe(true)
expect(sameValue('x', { a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
expect(sameValue('x', ['a', 'b'], ['b', 'a'])).toBe(false)
expect(sameValue('proxy', null, { type: 'None', address: 'x' })).toBe(true)
expect(sameValue('x', 5, '5')).toBe(false)
})
})
describe('comparing', () => {
const live = (x: Record<string, unknown>, y: Record<string, unknown>) => ({ a: { settings: settingsOf(x) }, b: { settings: settingsOf(y) } })
it('says same, differs and local-only against a baseline', () => {
const baseline = settingsOf()
const out = compareSettings({
baseline,
live: live({}, { dnsServerDomain: 'b.example.net', defaultRecordTtl: 1 }),
extraLocal: [],
})
const by = Object.fromEntries(out.servers.map((s) => [s.id, s]))
expect(by.a.status).toBe('same')
expect(by.b).toMatchObject({ status: 'differs', differing: 1, localDiffering: 1 })
expect(out.fields.map((f) => f.name)).toEqual(['defaultRecordTtl', 'dnsServerDomain'])
const onlyLocal = compareSettings({ baseline, live: live({}, { dnsServerDomain: 'b.example.net' }), extraLocal: [] })
expect(onlyLocal.servers.find((s) => s.id === 'b')!.status).toBe('local-only')
})
it('compares with the first server when there is no baseline', () => {
const out = compareSettings({ baseline: null, live: live({}, { forwarders: ['8.8.8.8'] }), extraLocal: [] })
expect(out.withBaseline).toBe(false)
expect(out.fields.map((f) => f.name)).toEqual(['forwarders'])
expect(out.servers.every((s) => s.status === 'differs')).toBe(true)
})
it('marks an unreachable server unknown', () => {
const out = compareSettings({ baseline: settingsOf(), live: { a: { settings: settingsOf() }, b: { error: 'timed out' } }, extraLocal: [] })
expect(out.servers.find((s) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown', error: 'timed out' })
})
it('counts an extra local field as local, not as a difference', () => {
const out = compareSettings({ baseline: settingsOf(), live: live({}, { defaultRecordTtl: 1 }), extraLocal: ['defaultRecordTtl'] })
expect(out.servers.find((s) => s.id === 'b')!.status).toBe('local-only')
})
it('never puts a secret value in a row, and says only that a secret differs', () => {
const other = [{ keyName: 'transfer.key', sharedSecret: 'DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }]
const out = compareSettings({ baseline: settingsOf(), live: live({}, { tsigKeys: other, proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } }), extraLocal: [] })
const text = JSON.stringify(out)
for (const secret of [TSIG, PROXY, CERT, 'DIFFERENT-SECRET']) expect(text).not.toContain(secret)
expect(out.fields.find((f) => f.name === 'tsigKeys')).toMatchObject({ secret: true, cells: { b: { differs: true, secretChanged: true } } })
})
it('masks every secret of a settings object', () => {
const text = JSON.stringify(maskSettings(settingsOf({ proxy: { type: 'Http', address: 'p', port: 1, username: 'u', password: PROXY, bypass: [] } })))
for (const secret of [TSIG, PROXY, CERT]) expect(text).not.toContain(secret)
expect(text).toContain(SECRET_PLACEHOLDER)
})
})
describe('what an apply would send', () => {
it('lists only shared fields that differ and that both sides have', () => {
const baseline = settingsOf({ defaultRecordTtl: 1, webServiceHttpPort: 1, dnsServerDomain: 'x', onlyInBaseline: true })
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), [])).toEqual(['defaultRecordTtl'])
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2 }), ['defaultRecordTtl'])).toEqual([])
expect(changedFields(baseline, settingsOf({ defaultRecordTtl: 2, blockingType: 'x' }), [], ['blockingType'])).toEqual(['blockingType'])
})
})
describe('encoding for settings/set', () => {
const one = (name: string, value: unknown) => {
const r = toParams(name, value)
if ('error' in r) throw new Error(r.error)
return r.params
}
it('encodes flags, numbers and text', () => {
expect(one('dnssecValidation', true)).toEqual({ dnssecValidation: 'true' })
expect(one('blockingAnswerTtl', 30)).toEqual({ blockingAnswerTtl: '30' })
expect(one('recursion', 'Allow')).toEqual({ recursion: 'Allow' })
expect(one('eDnsClientSubnetIpv4Override', null)).toEqual({ eDnsClientSubnetIpv4Override: '' })
})
it('joins lists with commas and sends an empty one as false', () => {
expect(one('forwarders', ['1.1.1.1', '9.9.9.9'])).toEqual({ forwarders: '1.1.1.1,9.9.9.9' })
expect(one('forwarders', [])).toEqual({ forwarders: 'false' })
expect(one('blockListUrls', ['https://a.example/x', '# Privacy'])).toEqual({ blockListUrls: 'https://a.example/x,# Privacy' })
})
it('refuses an entry the server would split', () => {
expect(toParams('blockListUrls', ['https://a.example/x,y'])).toHaveProperty('error')
})
it('sends tsigKeys and the QPM limits as pipe-joined rows, and false when empty', () => {
expect(one('tsigKeys', [{ keyName: 'k', sharedSecret: 's', algorithmName: 'hmac-sha256' }])).toEqual({ tsigKeys: 'k|s|hmac-sha256' })
expect(one('tsigKeys', [])).toEqual({ tsigKeys: 'false' })
expect(one('qpmPrefixLimitsIPv4', [{ prefix: 32, udpLimit: 600, tcpLimit: 500 }, { prefix: 24, udpLimit: 1, tcpLimit: 2 }])).toEqual({ qpmPrefixLimitsIPv4: '32|600|500|24|1|2' })
expect(one('qpmPrefixLimitsIPv6', [])).toEqual({ qpmPrefixLimitsIPv6: 'false' })
expect(toParams('tsigKeys', [{ keyName: 'k|x', sharedSecret: 's', algorithmName: 'a' }])).toHaveProperty('error')
})
it('sends the proxy flat, and only its type when there is none', () => {
expect(one('proxy', null)).toEqual({ proxyType: 'none' })
expect(one('proxy', { type: 'None', address: 'x' })).toEqual({ proxyType: 'none' })
expect(one('proxy', { type: 'Socks5', address: 'p.example', port: 1080, username: 'u', password: 'pw', bypass: ['127.0.0.1', 'lan'] })).toEqual({
proxyType: 'socks5',
proxyAddress: 'p.example',
proxyPort: '1080',
proxyUsername: 'u',
proxyPassword: 'pw',
proxyBypass: '127.0.0.1,lan',
})
})
it('refuses a name that is not a setting', () => {
expect(toParams('a=b&c', 1)).toHaveProperty('error')
})
})
describe('secrets coming back from the baseline editor', () => {
const old = normalize(settingsOf())
it('keeps the stored secret behind a placeholder, and takes a typed one', () => {
const kept = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old)
expect((kept.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe(TSIG)
const typed = restoreSecrets({ tsigKeys: [{ keyName: 'transfer.key', sharedSecret: 'NEW', algorithmName: 'x' }] }, old)
expect((typed.tsigKeys as { sharedSecret: string }[])[0].sharedSecret).toBe('NEW')
})
it('refuses a placeholder with nothing to stand for', () => {
expect(() => restoreSecrets({ tsigKeys: [{ keyName: 'new.key', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }, old)).toThrow(/transfer|new\.key/)
expect(() => restoreSecrets({ proxy: { type: 'Http', password: SECRET_PLACEHOLDER } }, old)).toThrow(/proxy/)
})
})
describe('lists', () => {
it('parses an export and compares as sets', () => {
expect(parseDomains('B.example.\n# note\n\na.example\r\na.example\n')).toEqual(['a.example', 'b.example'])
expect(diffSets(['a', 'b'], ['b', 'c'])).toEqual({ missing: ['a'], extra: ['c'] })
})
})
describe('routes and roles', () => {
const PATHS = [
'hub/settings/compare',
'hub/settings/preview',
'hub/settings/history/list',
'hub/settings/history/get',
'hub/settings/baseline/import',
'hub/settings/baseline/save',
'hub/settings/overrides/save',
'hub/settings/apply',
'hub/blocking/lists/compare',
'hub/blocking/lists/preview',
'hub/blocking/lists/baseline/import',
'hub/blocking/lists/apply',
]
it('needs admin on every endpoint, reads included', () => {
for (const p of PATHS) expect(routeFor(p)?.capability, p).toBe('admin')
for (const p of PATHS.filter((x) => /import|save|apply/.test(x))) expect(routeFor(p)?.kind, p).toBe('write-all')
})
it('refuses the other roles without touching a server', async () => {
for (const role of ROLES.filter((r) => r !== 'admin')) {
for (const p of PATHS) expect((await hub.call(role, p, { server: 'a', targets: 'a', text: '{}' })).code, `${role} ${p}`).toBe(403)
}
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(0)
})
})
describe('hub/settings', () => {
const importFrom = (server: string, extra: Record<string, string> = {}) => hub.call('admin', 'hub/settings/baseline/import', { server, ...extra })
const historyFiles = (): string[] => {
const dir = path.join(hub.dataDir, 'settings', 'history')
return existsSync(dir) ? readdirSync(dir) : []
}
it('compares the servers with each other before there is a baseline', async () => {
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
expect(r.body.response.withBaseline).toBe(false)
expect(r.body.response.baseline).toBeNull()
const names = r.body.response.fields.map((f: { name: string }) => f.name)
expect(names).toContain('defaultRecordTtl')
expect(names).toContain('dnsServerDomain')
expect(names).not.toContain('uptimestamp')
expect(r.body.response.localFields.builtIn).toContain('dnsServerDomain')
})
it('accepts a server as the baseline, then reports b as differing and a as same', async () => {
expect((await importFrom('a')).body.status).toBe('ok')
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s]))
expect(by.a.status).toBe('same')
expect(by.b.status).toBe('differs')
expect(by.b.differing).toBe(3)
expect(r.body.response.baseline.importedFrom).toBe('a')
// The import read with the admin token.
expect(a.calls.find((c) => c.path === 'settings/get')!.token).toBe('admin-a')
})
it('imports only the blocking fields with group=blocking', async () => {
await importFrom('a')
boxB().settings.blockingType = 'AnyAddress'
boxB().settings.defaultRecordTtl = 5
await importFrom('b', { group: 'blocking' })
const r = await hub.call('admin', 'hub/settings/compare', {}, 'GET')
const rows = r.body.response.fields.map((f: { name: string }) => f.name)
// The baseline now has b's blocking type and a's TTL: a differs in blocking, b in TTL.
expect(rows).toContain('blockingType')
expect(r.body.response.baseline.settings.blockingType).toBe('AnyAddress')
expect(r.body.response.baseline.settings.defaultRecordTtl).toBe(3600)
})
it('previews the fields that would change per target, and writes nothing', async () => {
await importFrom('a')
a.calls = []
b.calls = []
const r = await hub.call('admin', 'hub/settings/preview', { targets: 'a,b' })
const [pa, pb] = r.body.response.targets
expect(pa).toMatchObject({ server: 'a', ok: true, unchanged: true })
expect(pb.unchanged).toBe(false)
expect(pb.changes.kind).toBe('json')
expect(pb.changes.changes.map((c: { path: string }) => c.path)).toEqual(['blockingType', 'defaultRecordTtl', 'forwarders'])
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(0)
})
it('applies only the changed shared fields, never a local or webService field, and saves the old settings first', async () => {
await importFrom('a')
b.calls = []
const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' })
const expectHash = { b: preview.body.response.targets[0].currentHash }
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify(expectHash) })
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
expect(r.body.response.results[0].message).toMatch(/verified \(3 fields\)/)
const set = writes(b).filter((c) => c.path === 'settings/set')
expect(set).toHaveLength(1)
expect(set[0].token).toBe('admin-b')
expect(Object.fromEntries(set[0].form)).toEqual({ blockingType: 'NxDomain', defaultRecordTtl: '3600', forwarders: '1.1.1.1,9.9.9.9' })
expect(Object.keys(Object.fromEntries(set[0].form)).some((k) => k.startsWith('webService') || k === 'dnsServerDomain' || k === 'logFolder')).toBe(false)
expect(boxB().settings.dnsServerDomain).toBe('b.example.net')
// The history entry holds b's settings from before the change.
const files = historyFiles()
expect(files).toHaveLength(1)
expect(files[0]).toMatch(/__b__before-apply\.txt$/)
expect(JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'history', files[0]), 'utf8')).defaultRecordTtl).toBe(7200)
})
it('limits an apply to the blocking fields with group=blocking', async () => {
await importFrom('a')
b.calls = []
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', group: 'blocking' })
expect(r.body.response.results[0].ok).toBe(true)
expect(Object.keys(Object.fromEntries(writes(b)[0].form))).toEqual(['blockingType'])
expect(boxB().settings.defaultRecordTtl).toBe(7200)
})
it('refuses to apply when the server changed since the preview', async () => {
await importFrom('a')
const preview = await hub.call('admin', 'hub/settings/preview', { targets: 'b' })
boxB().settings.defaultRecordTtl = 99
b.calls = []
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b', expect: JSON.stringify({ b: preview.body.response.targets[0].currentHash }) })
expect(r.body.response.results[0]).toMatchObject({ ok: false })
expect(r.body.response.results[0].message).toMatch(/changed on the server since the preview/)
expect(writes(b)).toHaveLength(0)
})
it('reports a server that accepts the write but reads back something else', async () => {
await importFrom('a')
boxB().ignoreWrites = true
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(r.body.response.results[0].ok).toBe(false)
expect(r.body.response.results[0].message).toMatch(/reads back different values for: blockingType, defaultRecordTtl, forwarders/)
})
it('reports an unreachable target and a rejected write per server', async () => {
await importFrom('a')
b.on('settings/set', () => ({ error: 'Access was denied.' }))
const r = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: false, message: 'Access was denied.' })
b.down = true
const down = await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(down.body.response.results[0]).toMatchObject({ server: 'b', ok: false })
})
it('leaves the blocking switch alone while blocking is paused on the server', async () => {
await importFrom('a')
boxB().settings.enableBlocking = false
boxB().settings.temporaryDisableBlockingTill = '2026-12-01T00:00:00Z'
b.calls = []
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('enableBlocking')
})
it('does not send a field the server does not report', async () => {
await importFrom('a')
delete boxB().settings.forwarders
b.calls = []
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('forwarders')
})
it('treats a field added to the local list as not to be sent', async () => {
await importFrom('a')
expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'defaultRecordTtl' })).body.status).toBe('ok')
b.calls = []
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
expect(Object.fromEntries(writes(b)[0].form)).not.toHaveProperty('defaultRecordTtl')
expect((await hub.call('admin', 'hub/settings/overrides/save', { paths: 'bad name' })).code).toBe(400)
await hub.call('admin', 'hub/settings/overrides/save', { paths: '' })
})
it('keeps a secret behind the placeholder when the baseline is saved from the editor', async () => {
await importFrom('a')
const shown = (await hub.call('admin', 'hub/settings/compare', {}, 'GET')).body.response.baseline.settings
expect(shown.tsigKeys[0].sharedSecret).toBe(SECRET_PLACEHOLDER)
const saved = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ ...shown, defaultRecordTtl: 1800 }) })
expect(saved.body.status).toBe('ok')
const stored = JSON.parse(readFileSync(path.join(hub.dataDir, 'settings', 'baseline.json'), 'utf8'))
expect(stored.settings.tsigKeys[0].sharedSecret).toBe(TSIG)
expect(stored.settings.defaultRecordTtl).toBe(1800)
// The old baseline went to the history.
expect(historyFiles().some((f) => f.endsWith('__baseline__before-save.txt'))).toBe(true)
const bad = await hub.call('admin', 'hub/settings/baseline/save', { text: JSON.stringify({ tsigKeys: [{ keyName: 'other', sharedSecret: SECRET_PLACEHOLDER, algorithmName: 'x' }] }) })
expect(bad.code).toBe(400)
expect((await hub.call('admin', 'hub/settings/baseline/save', { text: '{nope' })).code).toBe(400)
})
it('lists and shows history without a secret', async () => {
await importFrom('a')
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET')
expect(list.body.response.entries.length).toBeGreaterThan(0)
const entry = list.body.response.entries.find((e: { server: string }) => e.server === 'b')
const got = await hub.call('admin', 'hub/settings/history/get', { id: entry.id }, 'GET')
expect(got.body.response.settings.defaultRecordTtl).toBe(7200)
expect(JSON.stringify(got.body)).not.toContain(TSIG)
expect((await hub.call('admin', 'hub/settings/history/get', { id: '../../users.json' }, 'GET')).code).toBe(400)
})
it('never lets a secret into any response or into the audit log', async () => {
boxB().settings.tsigKeys = [{ keyName: 'transfer.key', sharedSecret: 'B-DIFFERENT-SECRET', algorithmName: 'hmac-sha256' }]
boxB().settings.proxy = { type: 'Http', address: 'p.example', port: 8080, username: 'u', password: PROXY, bypass: [] }
const bodies: string[] = []
const keep = async (p: Promise<{ body: unknown }>) => bodies.push(JSON.stringify((await p).body))
await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET'))
await keep(hub.call('admin', 'hub/settings/baseline/import', { server: 'a' }))
await keep(hub.call('admin', 'hub/settings/compare', {}, 'GET'))
await keep(hub.call('admin', 'hub/settings/preview', { targets: 'a,b' }))
await keep(hub.call('admin', 'hub/settings/apply', { targets: 'b' }))
await keep(hub.call('admin', 'hub/settings/history/list', {}, 'GET'))
const list = await hub.call('admin', 'hub/settings/history/list', {}, 'GET')
for (const e of list.body.response.entries) await keep(hub.call('admin', 'hub/settings/history/get', { id: e.id }, 'GET'))
// And what the existing settings/get gives a viewer.
const viewer = await hub.call('viewer', 'settings/get', { node: 'b' }, 'GET')
bodies.push(JSON.stringify(viewer.body))
const audit = JSON.stringify(await hub.audit())
for (const secret of [TSIG, PROXY, CERT, 'B-DIFFERENT-SECRET']) {
for (const body of bodies) expect(body).not.toContain(secret)
expect(audit).not.toContain(secret)
}
// The apply did send the baseline's TSIG key to b; that travels to the server, not to the console.
expect(JSON.stringify(writes(b).map((c) => [...c.form]))).toContain(TSIG)
})
it('writes audit lines with field names and no values', async () => {
await importFrom('a')
await hub.call('admin', 'hub/settings/apply', { targets: 'b' })
const line = (await hub.audit()).find((e) => e.action === 'settings.apply' && e.server === 'b')!
expect(line.outcome).toBe('ok')
expect(line.detail).toMatch(/fields: blockingType, defaultRecordTtl, forwarders/)
expect(line.detail).not.toContain('8.8.8.8')
})
})
describe('hub/blocking/lists', () => {
const importFrom = (server: string) => hub.call('admin', 'hub/blocking/lists/baseline/import', { server })
it('compares as sets, per server: missing and extra', async () => {
await importFrom('a')
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
const by = Object.fromEntries(r.body.response.servers.map((s: { id: string }) => [s.id, s]))
expect(by.a.status).toBe('same')
expect(by.b.status).toBe('differs')
expect(by.b.blocked).toMatchObject({ count: 2, missing: 1, extra: 1, missingSample: ['ads.example'], extraSample: ['only-b.example'] })
expect(by.b.allowed).toMatchObject({ missing: 0, extra: 1, extraSample: ['extra-allowed.example'] })
})
it('compares with the first server before there is a baseline', async () => {
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
expect(r.body.response.withBaseline).toBe(false)
expect(r.body.response.servers.map((s: { status: string }) => s.status)).toEqual(['differs', 'differs'])
})
it('marks an unreachable server unknown', async () => {
await importFrom('a')
b.down = true
const r = await hub.call('admin', 'hub/blocking/lists/compare', {}, 'GET')
expect(r.body.response.servers.find((s: { id: string }) => s.id === 'b')).toMatchObject({ ok: false, status: 'unknown' })
})
it('adds the missing domains and leaves the extras unless asked', async () => {
await importFrom('a')
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
// Extras remain: they are not what the user asked to change.
expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example', 'only-b.example'])
expect(writes(b).some((c) => c.path.endsWith('/delete'))).toBe(false)
})
it('removes the extras only when the user asked', async () => {
await importFrom('a')
b.calls = []
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' })
expect(r.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
expect(boxB().blocked.sort()).toEqual(['ads.example', 'both.example'])
expect(boxB().allowed).toEqual(['ok.example'])
expect(writes(b).every((c) => c.token === 'admin-b')).toBe(true)
// Before deleting, the server's lists went to the history.
const dir = path.join(hub.dataDir, 'settings', 'history')
expect(readdirSync(dir).some((f) => f.endsWith('__b__before-lists.txt'))).toBe(true)
})
it('previews the counts and warns when a change is too large', async () => {
await importFrom('a')
const r = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' })
expect(r.body.response.targets[0]).toMatchObject({ id: 'b', ok: true, status: 'differs' })
boxB().blocked = Array.from({ length: 1200 }, (_, i) => `d${i}.example`)
const big = await hub.call('admin', 'hub/blocking/lists/preview', { targets: 'b', removeExtras: 'true' })
expect(big.body.response.targets[0].tooMany).toMatch(/at most/)
const applied = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b', removeExtras: 'true' })
expect(applied.body.response.results[0].ok).toBe(false)
})
it('is a no-op when the lists already match, and reports a rejected write', async () => {
await importFrom('a')
const same = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'a' })
expect(same.body.response.results[0].message).toMatch(/Already in sync/)
b.on('blocked/add', () => ({ error: 'Access was denied.' }))
const r = await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })
expect(r.body.response.results[0].ok).toBe(false)
expect(r.body.response.results[0].message).toMatch(/failed/)
})
it('refuses to apply without a baseline', async () => {
rmSync(path.join(hub.dataDir, 'blocking', 'baseline.json'), { force: true })
expect((await hub.call('admin', 'hub/blocking/lists/apply', { targets: 'b' })).code).toBe(400)
})
})