Files
technitium-console/hub/profiles.test.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:11:19 +07:00

373 lines
18 KiB
TypeScript

// @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<string, string>
configs: Record<string, string>
store: { name: string; version: string }[]
settings: Record<string, unknown>
blocked: string[]
allowed: string[]
failSettings: boolean
}
const boxes = new Map<Fake, Box>()
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<string, unknown> = {}): Record<string, unknown> => ({
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<string, string> = {}) =>
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('<secret>')
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: '<secret>' }] }, 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(/<secret>/)
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<string, string>) => [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<string, string>) => [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<string, string>) => [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([])
})
})