// @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 = {}): Record { 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 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() 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, y: Record) => ({ 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 = {}) => 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) }) })