// @vitest-environment node import http from 'node:http' import type { AddressInfo } from 'node:net' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { parseConfig } from './config.ts' import { createHub } from './server.ts' import { countDifferences } from './apps-matrix.ts' import { jsonDiff } from './json-diff.ts' interface FakeApp { version: string config: string | null updateAvailable?: boolean updateVersion?: string } interface Fake { server: http.Server url: string apps: Record down: boolean /** Config reads with the read token are refused with a permission error. */ denyRead: boolean tokens: string[] } function fake(apps: Fake['apps']): Promise { return new Promise((resolve) => { const f: Fake = { server: http.createServer(), url: '', apps, down: false, denyRead: false, tokens: [] } f.server.on('request', (req, res) => { if (f.down) return void req.socket.destroy() const url = new URL(req.url ?? '/', 'http://fake') const apiPath = url.pathname.replace(/^\/api\//, '') const token = url.searchParams.get('token') ?? '' res.setHeader('content-type', 'application/json') if (apiPath === 'apps/list') { const list = Object.entries(f.apps).map(([name, a]) => ({ name, description: `${name} description`, version: a.version, ...(a.updateAvailable ? { updateAvailable: true, updateVersion: a.updateVersion } : {}), })) return void res.end(JSON.stringify({ status: 'ok', response: { apps: list } })) } if (apiPath === 'apps/config/get') { f.tokens.push(token) if (f.denyRead && !token.startsWith('admin-')) { return void res.end(JSON.stringify({ status: 'error', errorMessage: "Access was denied to user 'reader' for apps." })) } const app = f.apps[url.searchParams.get('name') ?? ''] return void res.end(JSON.stringify({ status: 'ok', response: { config: app?.config ?? null } })) } res.end(JSON.stringify({ status: 'ok', response: {} })) }) f.server.listen(0, '127.0.0.1', () => { f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}` resolve(f) }) }) } const json = (o: unknown): string => JSON.stringify(o, null, 2) let a: Fake let b: Fake let dataDir: string let staticDir: string let hub: http.Server let base: string let token: string function reset(): void { a.down = b.down = false a.denyRead = b.denyRead = false a.tokens = [] b.tokens = [] a.apps = { Same: { version: '1.0', config: json({ x: 1 }) }, Differs: { version: '2.0', config: json({ x: 1, y: { z: 2 }, list: [1, 2] }) }, 'Only A': { version: '3.0', config: json({ secret: 'top-secret-value' }) }, Plain: { version: '4.0', config: 'line one\nline two\nline three' }, Empty: { version: '5.0', config: null }, } b.apps = { Same: { version: '1.0', config: json({ x: 1 }) }, Differs: { version: '2.1', updateAvailable: true, updateVersion: '2.2', config: json({ x: 2, y: { z: 3 }, list: [1, 2, 3] }) }, Plain: { version: '4.0', config: 'line one\nline TWO\nline three' }, Empty: { version: '5.0', config: null }, } } async function start(extra: Record = {}): Promise { hub?.close() hub = createHub( parseConfig({ HUB_SERVERS: `a=${a.url},b=${b.url}`, A_TOKEN: 'read-a', B_TOKEN: 'read-b', HUB_USER: 'admin', HUB_PASSWORD: 'secret', HUB_DATA: dataDir, HUB_STATIC: staticDir, HUB_CACHE_SECONDS: '0', HUB_TIMEOUT_MS: '2000', ...extra, }), ) await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)) base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}` const res = await fetch(`${base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=secret', }) token = ((await res.json()) as { token: string }).token } async function matrix() { const res = await fetch(`${base}/api/hub/apps/matrix`, { headers: { authorization: `Bearer ${token}` } }) const text = await res.text() return { text, body: JSON.parse(text) as Record } } const byName = (body: Record, name: string) => (body.response.apps as any[]).find((x) => x.name === name) beforeAll(async () => { a = await fake({}) b = await fake({}) dataDir = mkdtempSync(path.join(tmpdir(), 'hub-matrix-data-')) staticDir = mkdtempSync(path.join(tmpdir(), 'hub-matrix-static-')) }) afterAll(() => { hub?.close() a.server.close() b.server.close() rmSync(dataDir, { recursive: true, force: true }) rmSync(staticDir, { recursive: true, force: true }) }) beforeEach(async () => { reset() await start() }) describe('jsonDiff', () => { it('compares objects by key and arrays by index', () => { expect(jsonDiff({ a: 1 }, { a: 1 })).toEqual([]) expect(jsonDiff({ a: 1, b: { c: 2 } }, { a: 2, b: { c: 3 } })).toEqual([ { path: 'a', a: 1, b: 2 }, { path: 'b.c', a: 2, b: 3 }, ]) expect(jsonDiff([1, 2], [1, 3, 4])).toEqual([ { path: '[1]', a: 2, b: 3 }, { path: '[2]', a: undefined, b: 4 }, ]) }) it('reports type changes once and missing keys on either side', () => { expect(jsonDiff({ a: { x: 1 } }, { a: [1] })).toEqual([{ path: 'a', a: { x: 1 }, b: [1] }]) expect(jsonDiff({ a: null }, { a: 0 })).toEqual([{ path: 'a', a: null, b: 0 }]) expect(jsonDiff({ a: 1 }, { b: 1 })).toEqual([ { path: 'a', a: 1, b: undefined }, { path: 'b', a: undefined, b: 1 }, ]) }) }) describe('countDifferences', () => { it('counts leaf paths for JSON and lines otherwise', () => { expect(countDifferences([json({ a: 1, b: 2 }), json({ a: 2, b: 3 })])).toBe(2) expect(countDifferences(['one\ntwo', 'one\nTWO'])).toBe(2) expect(countDifferences([json({ a: 1 }), 'not json'])).toBeGreaterThan(0) expect(countDifferences([json({ a: 1 })])).toBe(0) }) }) describe('hub/apps/matrix', () => { it('has the documented shape and classifies each app', async () => { const { body } = await matrix() expect(body.status).toBe('ok') expect(body.hubWarnings).toEqual([]) expect(body.response.servers).toEqual([{ id: 'a', ok: true, writable: false }, { id: 'b', ok: true, writable: false }]) expect((body.response.apps as any[]).map((x) => x.name)).toEqual(['Differs', 'Empty', 'Only A', 'Plain', 'Same']) const same = byName(body, 'Same') expect(same).toMatchObject({ configs: 'same', versions: 'same', description: 'Same description' }) expect(same.diffCount).toBeUndefined() expect(same.servers.a).toMatchObject({ installed: true, version: '1.0', configStatus: 'read' }) expect(same.servers.a.configHash).toBe(same.servers.b.configHash) expect(same.servers.a.configHash).toMatch(/^[0-9a-f]{16}$/) const differs = byName(body, 'Differs') expect(differs).toMatchObject({ configs: 'different', versions: 'different', diffCount: 3 }) expect(differs.servers.b).toMatchObject({ updateAvailable: true, updateVersion: '2.2', version: '2.1' }) expect(differs.servers.a.updateAvailable).toBeUndefined() expect(byName(body, 'Plain')).toMatchObject({ configs: 'different', diffCount: 2 }) const only = byName(body, 'Only A') expect(only).toMatchObject({ configs: 'single', versions: 'single' }) expect(only.servers.b).toEqual({ installed: false, configStatus: 'none' }) const empty = byName(body, 'Empty') expect(empty).toMatchObject({ configs: 'same' }) expect(empty.servers.a).toMatchObject({ configStatus: 'none' }) expect(empty.servers.a.configHash).toBeUndefined() }) it('never returns a config text', async () => { const { text } = await matrix() expect(text).not.toContain('top-secret-value') expect(text).not.toContain('line one') expect(text).not.toContain('read-a') }) it('marks a config unreadable when the read token is refused and there is no admin token', async () => { a.denyRead = true const { body } = await matrix() const same = byName(body, 'Same') expect(same.servers.a).toMatchObject({ installed: true, configStatus: 'unreadable' }) expect(same.servers.a.configError).toMatch(/denied/) expect(same.servers.a.configHash).toBeUndefined() expect(same.configs).toBe('unknown') expect(byName(body, 'Only A').configs).toBe('unknown') // Versions are still known. expect(same.versions).toBe('same') }) it('retries with the admin token when the read token is refused', async () => { a.denyRead = true await start({ A_ADMIN_TOKEN: 'admin-a' }) const { body, text } = await matrix() expect(byName(body, 'Same')).toMatchObject({ configs: 'same' }) expect(byName(body, 'Same').servers.a.configStatus).toBe('read') expect(a.tokens).toContain('read-a') expect(a.tokens).toContain('admin-a') expect(b.tokens.every((t) => t === 'read-b')).toBe(true) expect(text).not.toContain('admin-a') }) it('shows a server that is down and still renders the rest', async () => { b.down = true const { body } = await matrix() expect(body.status).toBe('ok') expect(body.response.servers[0]).toEqual({ id: 'a', ok: true, writable: false }) expect(body.response.servers[1]).toMatchObject({ id: 'b', ok: false }) expect(body.response.servers[1].error).toBeTruthy() expect(body.hubWarnings).toHaveLength(1) expect(body.hubWarnings[0]).toMatch(/^b:/) const same = byName(body, 'Same') expect(same.servers.a.installed).toBe(true) expect(same.servers.b).toMatchObject({ installed: false, configStatus: 'unreadable' }) expect(same.configs).toBe('unknown') }) it('fails when no server answers', async () => { a.down = true b.down = true const { body } = await matrix() expect(body.status).toBe('error') expect(body.errorMessage).toMatch(/No server answered/) }) it('is open to every role that can read', async () => { const res = await fetch(`${base}/api/hub/apps/matrix`) expect(((await res.json()) as { status: string }).status).toBe('invalid-token') }) }) describe('session permissions', () => { it('lets the admin view and modify Apps (the other roles are in apps-write.test.ts)', async () => { const res = await fetch(`${base}/api/user/session/get`, { headers: { authorization: `Bearer ${token}` } }) const info = ((await res.json()) as any).info expect(info.permissions.Apps).toEqual({ canView: true, canModify: true, canDelete: true }) }) })