Files
multi-simadmin/test/phase-one-architecture.test.js
T

121 lines
6.6 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, access } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { FileConfigStore } from '../server/config/file-config-store.js'
import { ClientRegistry } from '../server/clients/client-registry.js'
import { buildApp } from '../server/app.js'
import { safeProxyPath } from '../server/proxy/service.js'
import { RequestCoordinator } from '../public/state/request-coordinator.js'
import { normalizeTargetUrl } from '../public/infrastructure/api-client.js'
async function fixtureConfig(instances = []) {
const dir = await mkdtemp(path.join(tmpdir(), 'multi-simadmin-phase1-'))
const configPath = path.join(dir, 'config.json')
const examplePath = path.join(dir, 'config.example.json')
await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances }))
return { dir, configPath, examplePath }
}
function fakeClient(instance, calls = []) {
return {
instance,
jar: { clear() {} },
ensureAuthenticated: async () => ({ authenticated: true }),
fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }),
request: async (url, options) => {
calls.push({ url, options })
return new Response('{}', { headers: { 'content-type': 'application/json', 'set-cookie': 'secret=x' } })
},
close() {},
}
}
test('FileConfigStore uses example only as template and commits immutable snapshots atomically', async () => {
const { configPath, examplePath } = await fixtureConfig()
const before = await readFile(examplePath, 'utf8')
const store = await FileConfigStore.open({ configPath, examplePath })
assert.equal(store.snapshot.server.host, '127.0.0.1')
assert.ok(Object.isFrozen(store.snapshot.instances))
await store.add({ id: 'one', url: 'http://192.0.2.10:3000' })
assert.equal(JSON.parse(await readFile(configPath, 'utf8')).instances[0].id, 'one')
assert.equal(await readFile(examplePath, 'utf8'), before)
})
test('failed persistence does not publish memory and transactions serialize', async () => {
const { configPath, examplePath } = await fixtureConfig()
let writes = 0
const store = await FileConfigStore.open({ configPath, examplePath, atomicWrite: async () => { writes += 1; throw new Error('disk full') } })
await assert.rejects(store.add({ id: 'x', url: 'http://192.0.2.11' }), /disk full/)
assert.equal(store.snapshot.instances.length, 0)
assert.equal(writes, 1)
await assert.rejects(access(configPath))
})
test('ClientRegistry reconciles only committed config and handles rename/delete', () => {
const closed = []
const registry = new ClientRegistry({ createClient: i => ({ instance: i, close: () => closed.push(i.id) }) })
registry.reconcile([{ id: 'old', url: 'http://a' }])
registry.reconcile([{ id: 'new', url: 'http://a' }])
assert.equal(registry.get('old'), undefined)
assert.equal(registry.get('new').instance.id, 'new')
assert.deepEqual(closed, ['old'])
registry.close()
assert.deepEqual(closed, ['old', 'new'])
})
test('proxy path and target URL contracts reject origin escape vectors', () => {
assert.equal(safeProxyPath('api/device'), '/api/device')
for (const value of ['../evil', 'api\\evil', '%2f%2fevil', '%5cevil', 'api/%2e%2e/x']) {
assert.throws(() => safeProxyPath(value), /invalid proxy path/)
}
assert.equal(normalizeTargetUrl('https://example.test/'), 'https://example.test')
assert.throws(() => normalizeTargetUrl('file:///tmp/x'), /http or https/)
})
test('buildApp supports inject without listening and covers health/readiness/config/auth/proxy/404', async t => {
const { configPath, examplePath } = await fixtureConfig([{ id: 'one', url: 'http://192.0.2.10:3000' }])
const store = await FileConfigStore.open({ configPath, examplePath })
const calls = []
const registry = new ClientRegistry({ createClient: instance => fakeClient(instance, calls) }).reconcile(store.snapshot.instances)
const testPolicy = { authorize: (_method, path) => path === '/api/test' ? { allowed: true, dangerous: false } : { allowed: false, statusCode: 403, reason: 'test policy' } }
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy })
t.after(() => app.close())
assert.deepEqual((await app.inject('/api/health')).json(), { ok: true })
assert.deepEqual((await app.inject('/api/ready')).json(), { ready: true })
assert.equal((await app.inject('/api/config')).statusCode, 200)
const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'two', url: 'https://192.0.2.15' } })
assert.equal(created.statusCode, 200)
const renamed = await app.inject({ method: 'PUT', url: '/api/instances/two', payload: { id: 'renamed' } })
assert.equal(renamed.json().instance.id, 'renamed')
assert.equal(registry.get('two'), undefined)
assert.ok(registry.get('renamed'))
const removed = await app.inject({ method: 'DELETE', url: '/api/instances/renamed' })
assert.equal(removed.json().removed.id, 'renamed')
assert.equal((await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: {} })).statusCode, 200)
assert.deepEqual((await app.inject({ method: 'POST', url: '/api/instances/one/logout' })).json(), { ok: true })
assert.equal((await app.inject('/api/status/one')).statusCode, 200)
const proxy = await app.inject({ method: 'POST', url: '/api/proxy/one/api/test', headers: { cookie: 'browser=x', authorization: 'Bearer x', forwarded: 'bad', 'content-type': 'application/json' }, payload: { hello: 'world' } })
assert.equal(proxy.statusCode, 200)
assert.equal(proxy.headers['set-cookie'], undefined)
assert.deepEqual(proxy.json(), {})
assert.equal(calls[0].options.headers.has('cookie'), false)
assert.equal(calls[0].options.headers.has('authorization'), false)
assert.equal(calls[0].options.headers.has('forwarded'), false)
assert.equal(calls[0].options.body, JSON.stringify({ hello: 'world' }))
assert.equal(new URL(calls[0].url).origin, 'http://192.0.2.10:3000')
assert.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' })
})
test('request coordinator is latest-wins per owner and binds result to request owner', async () => {
const coordinator = new RequestCoordinator()
let finishFirst
const first = coordinator.run('status', 'device-a', () => new Promise(resolve => { finishFirst = resolve }))
const second = coordinator.run('status', 'device-b', async () => 'new')
assert.deepEqual(await second, { accepted: true, ownerId: 'device-b', value: 'new' })
finishFirst('old')
assert.deepEqual(await first, { accepted: false, ownerId: 'device-a', value: 'old' })
})