// @vitest-environment node /* Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and import, the preview, and the order and the stop of an apply, on two fake servers that keep their apps, configs, settings and lists. */ import { existsSync, readFileSync, readdirSync } 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 { cleanProfile } from './profiles.ts' import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts' interface Box { apps: Record configs: Record store: { name: string; version: string }[] settings: Record blocked: string[] allowed: string[] failSettings: boolean } const boxes = new Map() const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip` function install(f: Fake, box: Box): void { boxes.set(f, box) f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } })) f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } })) f.on('apps/downloadAndInstall', (c: Call) => { const name = c.form.get('name') ?? c.query.get('name') ?? '' const url = c.form.get('url') ?? c.query.get('url') ?? '' const entry = box.store.find((a) => a.name === name) if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' } box.apps[name] = entry.version return { response: {} } }) f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } })) f.on('apps/config/set', (c: Call) => { box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? '' return { response: {} } }) f.on('settings/get', () => ({ response: box.settings })) f.on('settings/set', (c: Call) => { if (box.failSettings) return { error: 'The settings were refused.' } for (const [k, v] of c.form) { if (k === 'tsigKeys') { const cells = v.split('|') box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }] } else if (k in box.settings) box.settings[k] = v } return { response: {} } }) for (const list of ['blocked', 'allowed'] as const) { f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' })) f.on(`${list}/add`, (c: Call) => { box[list].push(c.form.get('domain') ?? '') return { response: {} } }) f.on(`${list}/delete`, (c: Call) => { box[list] = box[list].filter((d) => d !== c.form.get('domain')) return { response: {} } }) } } let a: Fake let b: Fake let hub: TestHub const boxA = (): Box => boxes.get(a)! const boxB = (): Box => boxes.get(b)! const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path) const CONFIG = JSON.stringify({ ttl: 30 }) const settingsOf = (over: Record = {}): Record => ({ dnsServerDomain: 'a.example', blockingType: 'NxDomain', tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }], ...over, }) function baselines(): void { writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' }) writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' }) writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' }) } beforeAll(async () => { a = await fake() b = await fake() hub = await startHub(a, b) }) afterAll(() => { hub.close() a.server.close() b.server.close() }) beforeEach(async () => { a.calls = [] b.calls = [] install(a, { apps: { Blocker: '2.0', Zipped: '1.0' }, configs: { Blocker: CONFIG }, store: [{ name: 'Blocker', version: '2.0' }], settings: settingsOf(), blocked: ['ads.example'], allowed: ['ok.example'], failSettings: false, }) // b is a fresh server: no apps, different settings, a missing domain, an extra one. install(b, { apps: {}, configs: {}, store: [{ name: 'Blocker', version: '2.0' }], settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }), blocked: ['mine.example'], allowed: ['ok.example'], failSettings: false, }) baselines() for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' }) }) const snapshot = (over: Record = {}) => hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over }) describe('making a profile', () => { it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => { const done = await snapshot() expect(done.code).toBe(200) expect(done.body.status).toBe('ok') const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8')) expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }]) // The host field stays out; the real secret is kept, as the baseline file has it. expect(file.settings.dnsServerDomain).toBeUndefined() expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET') expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] }) expect(file.updatedBy).toBe('admin') // Later changes of the baselines do not change the profile. writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' }) const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8')) expect(again.apps[0].config).toBe(CONFIG) }) it('lets the admin choose the parts, and refuses an empty choice', async () => { expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok') const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8')) expect(file.settings).toBeNull() expect(file.lists).toBeNull() const none = await snapshot({ apps: '', settings: 'false', lists: 'false' }) expect(none.code).toBe(400) expect(none.body.errorMessage).toMatch(/at least one part/) }) it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => { const zip = await snapshot({ apps: 'Zipped' }) expect(zip.code).toBe(400) expect(zip.body.errorMessage).toMatch(/store listing/) expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/) expect((await snapshot({ name: '../evil' })).code).toBe(400) expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false) }) it('replaces a profile of the same name and lists it with counts only', async () => { await snapshot() await snapshot({ lists: 'false' }) const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET') expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }]) expect(list.body.response.profiles[0].settings).toBeGreaterThan(0) }) }) describe('viewing a profile', () => { it('masks secrets and never returns an app config', async () => { await snapshot() const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET') const text = JSON.stringify(got.body) expect(text).not.toContain('REALSECRET') expect(text).toContain('') expect(text).not.toContain('"ttl"') expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }]) expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 }) }) it('answers an unknown profile with an error', async () => { expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400) }) }) describe('roles', () => { it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => { const reads = ['list', 'get', 'export', 'preview'] const writes = ['snapshot', 'delete', 'import', 'apply'] for (const role of ROLES.filter((r) => r !== 'admin')) { for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403) for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403) } // An unlisted profile path is refused, not forwarded. expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403) expect(a.calls).toEqual([]) expect(b.calls).toEqual([]) }) }) describe('export and import', () => { it('exports with the real secret, and imports it dry first, then for real', async () => { await snapshot() const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } }) expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/) const text = await res.text() expect(text).toContain('REALSECRET') const file = JSON.parse(text) expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 }) await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' }) const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' }) expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] }) expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false) const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' }) expect(done.body.response.applied).toBe(true) const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' }) expect(again.body.response.profiles[0].status).toBe('same') expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET') }) it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => { const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) }) expect(bad.code).toBe(400) const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '' }] }, lists: null }] } const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) }) expect(refused.code).toBe(400) expect(refused.body.errorMessage).toMatch(//) expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow() expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' }) }) it('accepts a body above the usual limit for an admin', async () => { const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] }) expect(big.length).toBeGreaterThan(64 * 1024) const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' }) expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' }) }) }) describe('preview', () => { it('says what each part would change on a target, and writes nothing', async () => { await snapshot() const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' }) expect(got.code).toBe(200) const [tb, ta] = got.body.response.targets expect(tb.server).toBe('b') expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }]) expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }]) expect(tb.settings.fields).toContain('blockingType') expect(tb.settings.secretFields).toContain('tsigKeys') expect(JSON.stringify(got.body)).not.toContain('REALSECRET') expect(tb.lists.blocked.missing).toBe(1) expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }]) expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }]) expect(ta.settings.unchanged).toBe(true) expect(order(a).concat(order(b))).toEqual([]) }) it('marks an installed app whose version differs, and an app the store does not offer', async () => { await snapshot() boxB().apps.Blocker = '1.0' const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' }) expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' }) boxB().apps = {} boxB().store = [] const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' }) expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store') }) }) describe('apply', () => { it('runs the steps in order on a fresh server: install, config, settings, lists', async () => { await snapshot() const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' }) expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true }) expect(done.body.response.results[0].steps.map((s: Record) => [s.step, s.status])).toEqual([ ['apps', 'ok'], ['configs', 'ok'], ['settings', 'ok'], ['lists', 'ok'], ]) expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add']) expect(boxB().apps.Blocker).toBe('2.0') expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 }) expect(boxB().settings.blockingType).toBe('NxDomain') // The host field was left alone, and the extra domain stays unless asked. expect(boxB().settings.dnsServerDomain).toBe('b.example') expect(boxB().blocked).toEqual(['mine.example', 'ads.example']) // The old config of a server that had one goes to the history; b had none, so nothing is claimed. expect(order(a)).toEqual([]) }) it('takes the install URL from the target own store, never from the request', async () => { await snapshot() await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' }) const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')! expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker')) expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false) // Each server is asked for its own listing, with its own read token. expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b') }) it('removes extra domains only when asked', async () => { await snapshot() await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' }) expect(boxB().blocked).toEqual(['ads.example']) }) it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => { await snapshot() boxB().failSettings = true boxA().settings.blockingType = 'AnyAddress' const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' }) const [ra, rb] = done.body.response.results expect(ra).toMatchObject({ server: 'a', ok: true }) expect(rb).toMatchObject({ server: 'b', ok: false }) expect(rb.message).toMatch(/Stopped at settings/) expect(rb.steps.map((s: Record) => [s.step, s.status])).toEqual([ ['apps', 'ok'], ['configs', 'ok'], ['settings', 'failed'], ['lists', 'not-run'], ]) expect(order(b)).not.toContain('blocked/add') const audit = await hub.audit() const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3) expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']]) expect(JSON.stringify(audit)).not.toContain('REALSECRET') }) it('retries from the failed step without redoing the earlier ones', async () => { await snapshot() boxB().failSettings = true await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' }) b.calls = [] boxB().failSettings = false const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) }) const r = again.body.response.results[0] expect(r.ok).toBe(true) expect(r.steps.map((s: Record) => [s.step, s.status])).toEqual([ ['apps', 'skipped'], ['configs', 'skipped'], ['settings', 'ok'], ['lists', 'ok'], ]) expect(order(b)).toEqual(['settings/set', 'blocked/add']) }) it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => { await snapshot() const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' }) expect(unknown.code).toBe(400) expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400) expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400) }) it('deletes a profile', async () => { await snapshot() expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true }) expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400) expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([]) }) })