Files
technitium-console/hub/apps-matrix.test.ts
T
DenozordecandClaude Opus 5.5 6acd2d656d Hub stage 2: install, update and uninstall apps on several servers
Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability.
Store URLs are resolved from each server's own listing. The config is saved
to history before an uninstall. Zip uploads are fanned out to the servers.
Every target's result is audited. The console gets a row menu on the
matrix, a target picker, a results dialog with Retry failed, the store and
install from file in All servers mode, and single-server actions for writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:40 +07:00

281 lines
11 KiB
TypeScript

// @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<string, FakeApp>
down: boolean
/** Config reads with the read token are refused with a permission error. */
denyRead: boolean
tokens: string[]
}
function fake(apps: Fake['apps']): Promise<Fake> {
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<string, string> = {}): Promise<void> {
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<void>((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<string, any> }
}
const byName = (body: Record<string, any>, 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 })
})
})