Hub stage 1: apps across servers, read-only
hub/apps/matrix reads apps/list and each app's config on every server (the read token first, the admin token once if a server refuses) and returns per app and server the version, available update and a config hash, plus whether versions and configs match and how many JSON leaf paths differ. Config texts never leave the hub. In hub mode the Apps section has the server selector: All servers shows the matrix with filters for differing versions, differing configs and updates; one server shows its list without write actions. The dev mock takes --variant a|b with differing apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ef2d53a65
commit
3d8df08fa0
14 files changed
+1082
-37
No files matched your search
@@ -0,0 +1,280 @@
|
||||
// @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 }, { id: 'b', ok: true }])
|
||||
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 })
|
||||
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 every role view Apps without modifying it', 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: false, canDelete: false })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user