Files
technitium-console/hub/apps-history.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

440 lines
21 KiB
TypeScript

// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import { acceptServerConfig } from './apps-sync.ts'
import { addUser, writeUsersFile } from './users.ts'
const json = (o: unknown): string => JSON.stringify(o, null, 2)
interface Fake {
server: http.Server
url: string
apps: Record<string, { version: string; config: string | null }>
calls: { path: string; token: string; method: string }[]
rejectSet: string | null
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const token = url.searchParams.get('token') ?? ''
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
f.calls.push({ path: apiPath, token, method: req.method ?? '' })
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
if (apiPath === 'apps/config/get') return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
if (apiPath === 'apps/config/set') {
if (!token.startsWith('admin-')) return error('Access was denied.')
if (f.rejectSet !== null) return error(f.rejectSet)
f.apps[name].config = form.get('config')
return ok()
}
ok()
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const V1 = json({ ttl: 30, db: '/base.db' })
const V2 = json({ ttl: 45, db: '/base.db' })
function reset(): void {
for (const f of [a, b]) {
f.calls = []
f.rejectSet = null
}
a.apps = { Blocker: { version: '1.0', config: V1 } }
b.apps = { Blocker: { version: '1.0', config: V1 } }
}
async function start(dir: string = dataDir): Promise<void> {
hub?.close()
hub = createHub(
parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'read-a',
B_TOKEN: 'read-b',
A_ADMIN_TOKEN: 'admin-a',
B_ADMIN_TOKEN: 'admin-b',
HUB_DATA: dir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
HUB_DRIFT_MINUTES: '0',
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
for (const role of ['viewer', 'apps-admin', 'admin']) {
const res = await fetch(`${base}/api/user/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: `user=${role}&pass=password-${role}`,
})
tokens[role] = ((await res.json()) as { token: string }).token
}
}
async function raw(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; form?: Record<string, string> } = {}) {
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
let body: string | undefined
if (init.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(init.json)
} else if (init.form !== undefined) {
headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(init.form).toString()
}
return fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: body === undefined ? 'GET' : 'POST', headers, body })
}
async function api(role: string, apiPath: string, init: Parameters<typeof raw>[2] = {}) {
const res = await raw(role, apiPath, init)
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const dirOf = (d: string = dataDir) => path.join(d, 'apps', 'Blocker')
const historyDir = (d: string = dataDir) => path.join(dirOf(d), 'history')
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
const list = async (role = 'viewer') => (await api(role, 'hub/apps/history/list', { params: { name: 'Blocker' } })).body.response.entries as Record<string, any>[]
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
beforeEach(async () => {
reset()
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
rmSync(path.join(dataDir, 'audit.log'), { force: true })
rmSync(path.join(dataDir, 'drift.json'), { force: true })
await start()
})
afterEach(() => {
hub?.close()
})
const post = (role: string, p: string, body: Record<string, unknown>) => api(role, p, { json: body })
const importFrom = (server: string) => post('apps-admin', 'hub/apps/baseline/import', { name: 'Blocker', server })
const saveBaseline = (text: string) => post('apps-admin', 'hub/apps/baseline/save', { name: 'Blocker', text })
describe('history', () => {
it('keeps the old baseline text on every save and import, and lists it without the text', async () => {
await importFrom('a')
expect(await list()).toEqual([])
await saveBaseline(V2)
await importFrom('b')
const entries = await list()
expect(entries).toHaveLength(2)
expect(entries[0]).toMatchObject({ server: 'baseline', kind: 'before-save', size: V2.length })
expect(entries[0].at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)
expect(entries[0].hash).toMatch(/^[0-9a-f]{16}$/)
expect(entries[0].text).toBeUndefined()
expect(entries[1].size).toBe(V1.length)
expect(entries[0].id > entries[1].id).toBe(true)
})
it('does not add an entry when the baseline text did not change', async () => {
await importFrom('a')
await saveBaseline(V1)
expect(await list()).toEqual([])
})
it('reads one entry by the id the listing gives and refuses anything else', async () => {
await importFrom('a')
await saveBaseline(V2)
const [entry] = await list()
const got = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })
expect(got.body.response.text).toBe(V1)
for (const id of ['../../../users.json', '..\\..\\users.json', entry.id.replace('2', '3'), entry.id + '/', '/etc/passwd', '', `../history/${entry.id}`, 'baseline.json']) {
const r = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id } })
expect([id, r.code]).toEqual([id, 400])
}
// The app name cannot walk out either.
const r = await api('viewer', 'hub/apps/history/get', { params: { name: '../..', id: 'users.json' } })
expect(r.code).toBe(400)
})
it('ignores files in the folder that are not history entries', async () => {
await importFrom('a')
mkdirSync(historyDir(), { recursive: true })
writeFileSync(path.join(historyDir(), 'notes.txt'), 'x')
expect(await list()).toEqual([])
})
it('shows what a saved version would change on a server', async () => {
await importFrom('a')
await saveBaseline(V2)
const [entry] = await list()
const r = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'b' } })
expect(r.body.response).toMatchObject({ server: 'b', writable: true, unchanged: true, current: V1 })
a.apps.Blocker.config = V2
const d = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'a' } })
expect(d.body.response.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 45, 30]])
expect(d.body.response.unchanged).toBe(false)
})
it('keeps at most 50 entries per app', async () => {
await importFrom('a')
for (let i = 0; i < 55; i++) await saveBaseline(json({ n: i }))
expect((await list()).length).toBe(50)
})
it('also records the config a single-server set replaced', async () => {
await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
const entries = await list()
expect(entries[0]).toMatchObject({ server: 'a', kind: 'before-set' })
})
})
describe('rollback and restore', () => {
async function twoVersions() {
// Both servers were changed by the hub once: their old config (V1) is in the history.
await importFrom('a')
await saveBaseline(V2)
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['a', 'b'] })
return (await list()).filter((e) => e.kind === 'before-apply')
}
it('rolls a version back to the chosen servers, one result each, history first, verified', async () => {
const applied = await twoVersions()
expect(applied.map((e) => e.server).sort()).toEqual(['a', 'b'])
expect(a.apps.Blocker.config).toBe(V2)
const entry = applied.find((e) => e.server === 'a') as Record<string, any>
b.rejectSet = 'Disk full.'
const r = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a', 'b'] })
expect(r.body.response.results).toEqual([
{ server: 'a', ok: true, message: 'Applied and verified.' },
{ server: 'b', ok: false, message: 'Disk full.' },
])
expect(a.apps.Blocker.config).toBe(V1)
expect(b.apps.Blocker.config).toBe(V2)
// The config rolled away from is kept too.
const files = readdirSync(historyDir()).filter((f) => f.endsWith('__a__before-apply.txt'))
expect(files).toHaveLength(2)
expect(writes(a).at(-1)?.token).toBe('admin-a')
})
it('refuses a rollback when the config changed since the preview, and for an old baseline entry', async () => {
const applied = await twoVersions()
const entry = applied[0]
const stale = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'], expect: { a: 'deadbeefdeadbeef' } })
expect(stale.body.response.results[0].message).toMatch(/changed on the server since the preview/)
const baselineEntry = (await list()).find((e) => e.kind === 'before-save') as Record<string, any>
const refused = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: baselineEntry.id, targets: ['a'] })
expect(refused.code).toBe(400)
expect(refused.body.errorMessage).toMatch(/Restore it as the baseline/)
})
it('restores an old baseline as the baseline, keeping the current one in the history', async () => {
await importFrom('a')
await saveBaseline(V2)
const old = (await list())[0]
const r = await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: old.id })
expect(r.body.response.baseline.text).toBe(V1)
const entries = await list()
expect(entries[0]).toMatchObject({ kind: 'before-save', size: V2.length })
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: 'nope' })).code).toBe(400)
// A server entry is not a baseline.
b.apps.Blocker.config = V2
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
const serverEntry = (await list()).find((e) => e.kind === 'before-apply') as Record<string, any>
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: serverEntry.id })).code).toBe(400)
})
it('lets a viewer look but not roll back or restore', async () => {
await twoVersions()
const [entry] = await list()
expect((await post('viewer', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'] })).code).toBe(403)
expect((await post('viewer', 'hub/apps/baseline/restore', { name: 'Blocker', id: entry.id })).code).toBe(403)
expect((await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })).code).toBe(200)
})
it('audits rollbacks and restores without any config text', async () => {
const applied = await twoVersions()
await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: applied[0].id, targets: ['a'] })
const text = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
expect(text).toContain('apps.history-rollback')
for (const secret of ['/base.db', 'admin-a', 'admin-b']) expect(text).not.toContain(secret)
})
})
describe('drift over the API', () => {
it('counts a drifted server in the session, shows it in drift/get and clears it when fixed', async () => {
await importFrom('a')
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
const checked = await post('apps-admin', 'hub/drift/check', {})
expect(checked.body.response.apps).toMatchObject([{ app: 'Blocker', server: 'b', kind: 'differs' }])
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
const got = await api('viewer', 'hub/drift/get')
expect(got.body.response.apps).toHaveLength(1)
expect(got.body.response.checking).toBe(false)
// Revert to the baseline is the sync apply on that server.
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
await post('apps-admin', 'hub/drift/check', {})
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
expect((await api('viewer', 'hub/drift/get')).body.response.apps).toEqual([])
expect(readFileSync(path.join(dataDir, 'drift.json'), 'utf8')).toContain('"apps": []')
})
it('accepts a drifted config as the baseline', async () => {
await importFrom('a')
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
const r = await post('apps-admin', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })
expect(JSON.parse(r.body.response.baseline.text)).toEqual({ ttl: 99, db: '/base.db' })
expect(r.body.response.baseline.importedFrom).toBe('b')
expect((await list())[0]).toMatchObject({ server: 'baseline', kind: 'before-save' })
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
expect((await post('viewer', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })).code).toBe(403)
})
it('on accept keeps local fields out of the baseline and moves the server values into the overrides', () => {
const out = acceptServerConfig(
json({ ttl: 99, db: '/b.db' }),
json({ ttl: 30, db: '/base.db' }),
{ paths: ['db'], values: { a: { db: '/a.db' } } },
'b',
)
expect(JSON.parse(out.baselineText)).toEqual({ ttl: 99, db: '/base.db' })
expect(out.overrides.values).toEqual({ a: { db: '/a.db' }, b: { db: '/b.db' } })
expect(() => acceptServerConfig('plain', null, { paths: ['db'], values: {} }, 'b')).toThrow(/plain text/)
})
})
describe('export and import of baselines', () => {
async function setup() {
await importFrom('a')
await post('apps-admin', 'hub/apps/overrides/save', { name: 'Blocker', paths: ['db'], values: JSON.stringify({ a: { db: '/a.db' }, b: { db: '/b.db' } }) })
}
it('exports one file with every baseline and its local fields, as a download', async () => {
await setup()
const res = await raw('apps-admin', 'hub/apps/export')
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="hub-baselines-\d{4}-\d\d-\d\d\.json"$/)
const file = (await res.json()) as Record<string, any>
expect(file).toMatchObject({ hub: 'technitium-hub', version: 1 })
expect(file.apps).toHaveLength(1)
expect(file.apps[0]).toMatchObject({ app: 'Blocker', baseline: { text: V1 }, overrides: { paths: ['db'], values: { a: { db: '/a.db' } } } })
expect((await api('viewer', 'hub/apps/export')).code).toBe(403)
expect(readFileSync(path.join(dataDir, 'audit.log'), 'utf8')).toContain('apps.export')
})
it('imports into another hub: a dry run says what is new, applying writes it, and no server is touched', async () => {
await setup()
const file = await (await raw('apps-admin', 'hub/apps/export')).text()
const other = mkdtempSync(path.join(tmpdir(), 'hub-hist-other-'))
writeUsersFile(other, [...(await import('./users.ts')).readUsersFile(dataDir)!])
try {
await start(other)
const before = a.calls.length + b.calls.length
const dry = await post('apps-admin', 'hub/apps/import', { data: file })
expect(dry.body.response).toEqual({ apps: [{ app: 'Blocker', status: 'new', ignoredServers: [] }], applied: false })
expect(readdirSync(other)).not.toContain('apps')
const done = await post('apps-admin', 'hub/apps/import', { data: file, apply: true })
expect(done.body.response.applied).toBe(true)
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'baseline.json'), 'utf8')).text).toBe(V1)
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'overrides.json'), 'utf8')).values.b).toEqual({ db: '/b.db' })
expect(a.calls.length + b.calls.length).toBe(before)
// The same file again changes nothing.
expect((await post('apps-admin', 'hub/apps/import', { data: file })).body.response.apps[0].status).toBe('same')
} finally {
rmSync(other, { recursive: true, force: true })
}
})
it('says changed, and keeps the replaced baseline in the history when applying', async () => {
await setup()
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
file.apps[0].baseline.text = V2
await saveBaseline('{"x":1}')
const dry = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
expect(dry.body.response.apps[0].status).toBe('changed')
await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file), apply: true })
expect(JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8')).text).toBe(V2)
expect((await list()).some((e) => e.kind === 'before-save' && e.size === '{"x":1}'.length)).toBe(true)
})
it('ignores local values of servers this hub does not have, and says so', async () => {
await setup()
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
file.apps[0].overrides.values.zzz = { db: '/z.db' }
const r = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
expect(r.body.response.apps[0].ignoredServers).toEqual(['zzz'])
})
it('refuses a file that is not valid, writing nothing', async () => {
await setup()
const good = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
const cases: [string, unknown][] = [
['not json', 'this is not json'],
['another file', { hello: 1 }],
['wrong version', { ...good, version: 2 }],
['no apps', { ...good, apps: 'x' }],
['bad json baseline', { ...good, apps: [{ app: 'X', baseline: { text: '{ nope' }, overrides: { paths: [], values: {} } }] }],
['no text', { ...good, apps: [{ app: 'X', baseline: {}, overrides: {} }] }],
['bad path', { ...good, apps: [{ app: 'X', baseline: { text: '{}' }, overrides: { paths: ['a b'], values: {} } }] }],
['duplicate app', { ...good, apps: [good.apps[0], good.apps[0]] }],
['text with local fields', { ...good, apps: [{ app: 'X', baseline: { text: 'plain' }, overrides: { paths: ['a'], values: {} } }] }],
]
for (const [label, content] of cases) {
const r = await post('apps-admin', 'hub/apps/import', { data: typeof content === 'string' ? content : JSON.stringify(content), apply: true })
expect([label, r.code]).toEqual([label, 400])
}
expect(readdirSync(path.join(dataDir, 'apps'))).toEqual(['Blocker'])
})
it('is for writers only, and accepts a file up to 5 MB from a signed-in caller', async () => {
const big = JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [{ app: 'Big', baseline: { text: JSON.stringify({ pad: 'x'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }, ...[1, 2, 3].map((n) => ({ app: `Big${n}`, baseline: { text: JSON.stringify({ pad: 'y'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }))] })
expect(big.length).toBeGreaterThan(2 * 1024 * 1024)
expect((await post('viewer', 'hub/apps/import', { data: big })).code).toBe(403)
const r = await post('apps-admin', 'hub/apps/import', { data: big })
expect(r.body.response.apps).toHaveLength(4)
})
})