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>
197 lines
7.5 KiB
TypeScript
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)
|
|
})
|
|
})
|