Files
technitium-console/hub/drift.test.ts
T
DenozordecandClaude Opus 5.5 57e492cceb Hub stage 4: config history, rollback, drift check, baseline export and import
Every saved config, and every earlier baseline, is listed per app. You
can view it, diff it against a server's current config, and roll it back
through preview and apply. Earlier baselines can be restored.

A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with
its effective config. Drift shows in the header and the matrix, with
Accept as baseline and Revert to baseline. All baselines export to one
file. Importing that file shows a dry run before it writes.

Rolling back preselects, and diffs against, the server the version came
from. The line-diff op "del" is renamed to "remove" for the language lint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:57:33 +07:00

197 lines
7.5 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, readFileSync, rmSync } 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, writeOverrides } from './store.ts'
import { Upstream } from './upstream.ts'
const json = (o: unknown): string => JSON.stringify(o, null, 2)
interface FakeServer {
apps: Record<string, string>
down: boolean
denyConfig: boolean
}
let dir: string
let servers: Record<string, FakeServer>
let calls: string[]
let gate: Promise<void> | 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 })
}
return reply({})
}) as typeof fetch
function make() {
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)
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-'))
servers = { a: { apps: {}, down: false, denyConfig: false }, b: { apps: {}, down: false, denyConfig: false } }
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([{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: '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.kind])).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([{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: '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.kind])).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 kind (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({ kind: '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<void>((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: [{ app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', kind: 'differs' }],
})
const again = make().drift
expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false })
expect(again.count()).toBe(1)
})
})