// @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 calls: { path: string; token: string; method: string }[] rejectSet: string | null } function fake(): Promise { 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 = {} 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 { 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((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; json?: unknown; form?: Record } = {}) { const headers: Record = { 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[2] = {}) { const res = await raw(role, apiPath, init) return { code: res.status, body: (await res.json()) as Record } } 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[] 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) => 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 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 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 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([{ kind: 'app', app: 'Blocker', name: 'Blocker', server: 'b', status: '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 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) }) })