// @vitest-environment node import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseConfig } from './config.ts' import { Drift } from './drift.ts' import { writeBaseline, writeListsBaseline, writeOverrides, writeSettingsBaseline } from './store.ts' import { Webhook } from './webhook.ts' import { Upstream } from './upstream.ts' const json = (o: unknown): string => JSON.stringify(o, null, 2) interface FakeServer { apps: Record down: boolean denyConfig: boolean settings: Record blocked: string[] allowed: string[] } let dir: string let servers: Record let calls: string[] let gate: Promise | null let clock: number /* A fetch that answers from memory: no sockets, no timers. */ const fakeFetch = (async (input: string | URL | Request) => { const url = new URL(String(input)) const id = url.port === '1' ? 'a' : 'b' const server = servers[id] const apiPath = url.pathname.replace(/^\/api\//, '') calls.push(`${id}:${apiPath}`) if (gate !== null) await gate if (server.down) throw new TypeError('fetch failed') const name = url.searchParams.get('name') ?? '' const reply = (response: unknown) => Response.json({ status: 'ok', response }) if (apiPath === 'apps/list') return reply({ apps: Object.keys(server.apps).map((n) => ({ name: n, version: '1.0' })) }) if (apiPath === 'apps/config/get') { if (server.denyConfig) return Response.json({ status: 'error', errorMessage: 'Access was denied.' }) return reply({ config: server.apps[name] ?? null }) } if (apiPath === 'settings/get') return reply(server.settings) if (apiPath === 'blocked/export' || apiPath === 'allowed/export') { return new Response((apiPath === 'blocked/export' ? server.blocked : server.allowed).join('\n') + '\n', { headers: { 'content-type': 'text/plain' } }) } return reply({}) }) as typeof fetch function make(webhook?: Webhook) { const config = parseConfig({ HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2', A_TOKEN: 'ra', B_TOKEN: 'rb', HUB_DATA: dir, HUB_CACHE_SECONDS: '0', }) const drift = new Drift(config, new Upstream(config, fakeFetch, () => clock), () => clock, { webhook }) return { config, drift } } const baseline = (app: string, text: string) => writeBaseline(dir, { app, text, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' }) beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'hub-drift-')) const blank = (): FakeServer => ({ apps: {}, down: false, denyConfig: false, settings: {}, blocked: [], allowed: [] }) servers = { a: blank(), b: blank() } calls = [] gate = null clock = Date.parse('2026-10-31T10:00:00Z') }) afterEach(() => rmSync(dir, { recursive: true, force: true })) describe('drift check', () => { it('finds nothing when no app has a baseline, and calls no server', async () => { servers.a.apps.X = json({ a: 1 }) const { drift } = make() const state = await drift.check() expect(state.apps).toEqual([]) expect(state.checkedAt).toBe('2026-10-31T10:00:00.000Z') expect(calls).toEqual([]) expect(drift.count()).toBe(0) }) it('reports a server that differs from the baseline and not one that matches', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) const { drift } = make() const state = await drift.check() expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }]) expect(drift.count()).toBe(1) }) it('compares by meaning, not by spacing or key order', async () => { baseline('Blocker', json({ a: 1, b: 2 })) servers.a.apps.Blocker = '{"b":2,"a":1}' servers.b.apps.Blocker = json({ a: 1, b: 2 }) expect((await make().drift.check()).apps).toEqual([]) }) it('uses each server own local values, so a local difference is not drift', async () => { baseline('Blocker', json({ ttl: 30, db: '/base' })) writeOverrides(dir, 'Blocker', { paths: ['db'], values: { a: { db: '/a.db' }, b: { db: '/b.db' } } }) servers.a.apps.Blocker = json({ ttl: 30, db: '/a.db' }) servers.b.apps.Blocker = json({ ttl: 30, db: '/b.db' }) const { drift } = make() expect((await drift.check()).apps).toEqual([]) // A wrong local value is drift. servers.b.apps.Blocker = json({ ttl: 30, db: '/elsewhere.db' }) expect((await drift.check()).apps.map((e) => [e.server, e.status])).toEqual([['b', 'differs']]) }) it('lists a server that does not answer as unreadable, and does not count it', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.down = true const { drift } = make() const state = await drift.check() expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'unreadable' }]) expect(drift.count()).toBe(0) }) it('lists a refused config read as unreadable and an app that is missing as not-installed', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.a.denyConfig = true const { drift } = make() const state = await drift.check() expect(state.apps.map((e) => [e.server, e.status])).toEqual([['a', 'unreadable'], ['b', 'not-installed']]) }) it('keeps `since` across checks and drops the entry once the problem is fixed', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) const { drift } = make() await drift.check() clock += 15 * 60_000 const second = await drift.check() expect(second.apps[0].since).toBe('2026-10-31T10:00:00.000Z') expect(second.checkedAt).toBe('2026-10-31T10:15:00.000Z') servers.b.apps.Blocker = json({ ttl: 30 }) clock += 15 * 60_000 expect((await drift.check()).apps).toEqual([]) expect(drift.count()).toBe(0) // It drifts again: a new `since`. servers.b.apps.Blocker = json({ ttl: 5 }) clock += 15 * 60_000 expect((await drift.check()).apps[0].since).toBe('2026-10-31T10:45:00.000Z') }) it('changes the status (and `since`) when the problem changes', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) const { drift } = make() await drift.check() servers.b.down = true clock += 60_000 const state = await drift.check() expect(state.apps[0]).toMatchObject({ status: 'unreadable', since: '2026-10-31T10:01:00.000Z' }) }) it('never runs two checks at once', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 30 }) let open!: () => void gate = new Promise((resolve) => (open = resolve)) const { drift } = make() const first = drift.check() const second = drift.check() expect(drift.get().checking).toBe(true) open() expect(await second).toBe(await first) // Two servers, two app lists and two config reads: one check's worth of calls. expect(calls).toHaveLength(4) expect(drift.get().checking).toBe(false) gate = null await drift.check() expect(calls).toHaveLength(8) }) it('keeps the last result in drift.json and loads it again', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) await make().drift.check() const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8')) expect(file).toEqual({ checkedAt: '2026-10-31T10:00:00.000Z', apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }], }) const again = make().drift expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false }) expect(again.count()).toBe(1) }) }) const baselineSettings = (settings: Record) => writeSettingsBaseline(dir, { settings, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' }) const baselineLists = (blocked: string[], allowed: string[]) => writeListsBaseline(dir, { blocked, allowed, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' }) describe('drift of settings and lists', () => { it('reports shared settings fields that differ from the baseline, by field name only', async () => { baselineSettings({ blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'x.example' }) servers.a.settings = { blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'a.example' } servers.b.settings = { blockingType: 'AnyAddress', defaultRecordTtl: 60, dnsServerDomain: 'b.example' } const { drift } = make() const state = await drift.check() // dnsServerDomain is local: server a is fine even though its value differs. expect(state.apps).toEqual([{ kind: 'settings', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] }]) expect(JSON.stringify(state)).not.toContain('AnyAddress') expect(drift.count()).toBe(1) }) it('reports Blocked and Allowed lists that differ, without a domain', async () => { baselineLists(['ads.example'], ['ok.example']) servers.a.blocked = ['ads.example'] servers.a.allowed = ['ok.example'] servers.b.blocked = ['ads.example', 'extra.example'] servers.b.allowed = ['ok.example'] const { drift } = make() const state = await drift.check() expect(state.apps).toEqual([{ kind: 'lists', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blocked'] }]) expect(JSON.stringify(state)).not.toContain('extra.example') }) it('reads no settings and no lists when there is no baseline for them', async () => { const { drift } = make() await drift.check() expect(calls).toEqual([]) }) it('lists a server that does not answer as unreadable for each baseline, and does not count it', async () => { baselineSettings({ blockingType: 'NxDomain' }) baselineLists([], []) servers.b.down = true const { drift } = make() const state = await drift.check() expect(state.apps.map((e) => [e.kind, e.server, e.status])).toEqual([['lists', 'b', 'unreadable'], ['settings', 'b', 'unreadable']]) expect(drift.count()).toBe(0) }) it('counts every kind in one number', async () => { baseline('Blocker', json({ ttl: 30 })) baselineSettings({ blockingType: 'NxDomain' }) baselineLists(['x.example'], []) servers.a.apps.Blocker = json({ ttl: 1 }) servers.b.apps.Blocker = json({ ttl: 30 }) servers.a.settings = { blockingType: 'AnyAddress' } servers.b.settings = { blockingType: 'NxDomain' } servers.a.blocked = ['x.example'] const { drift } = make() await drift.check() // a: app and settings; b: lists (the baseline has x.example, b has none). expect(drift.get().apps.map((e) => `${e.kind}:${e.server}`).sort()).toEqual(['app:a', 'lists:b', 'settings:a']) expect(drift.count()).toBe(3) }) it('loads a drift.json written before stage 8, where `kind` held the status', () => { writeFileSync( path.join(dir, 'drift.json'), JSON.stringify({ checkedAt: '2026-10-31T09:00:00.000Z', apps: [ { app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', kind: 'differs' }, { app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', kind: 'not-installed' }, { server: 'a', since: 'x', kind: 'bogus', app: 'Z' }, ], }), ) const { drift } = make() expect(drift.get().apps).toEqual([ { kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', status: 'differs' }, { kind: 'app', name: 'Other', app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', status: 'not-installed' }, ]) expect(drift.count()).toBe(1) }) }) describe('drift notices', () => { const sent: { event: string; items: Record[] }[] = [] const hook = () => new Webhook( { webhookUrl: 'https://hooks.example/x?token=SECRET', webhookSecret: null, domain: 'hub' }, { retryDelayMs: 0, fetch: (async (_url: unknown, init: RequestInit) => { sent.push(JSON.parse(String(init.body))) return new Response('ok') }) as typeof fetch, }, ) beforeEach(() => { sent.length = 0 }) it('tells what newly differs once, and what is cleared, and nothing when nothing changed', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) const webhook = hook() const { drift } = make(webhook) await drift.check() await webhook.settled() expect(sent.map((s) => s.event)).toEqual(['drift.found']) expect(sent[0].items).toMatchObject([{ kind: 'app', name: 'Blocker', server: 'b' }]) clock += 15 * 60_000 await drift.check() await webhook.settled() expect(sent).toHaveLength(1) servers.b.apps.Blocker = json({ ttl: 30 }) await drift.check() await webhook.settled() expect(sent.map((s) => s.event)).toEqual(['drift.found', 'drift.cleared']) }) it('does not repeat a notice after a restart, and does not call an unreadable server cleared', async () => { baseline('Blocker', json({ ttl: 30 })) servers.a.apps.Blocker = json({ ttl: 30 }) servers.b.apps.Blocker = json({ ttl: 99 }) await make().drift.check() const webhook = hook() const restarted = make(webhook).drift await restarted.check() await webhook.settled() expect(sent).toEqual([]) servers.b.down = true await restarted.check() await webhook.settled() expect(sent).toEqual([]) }) })