120 lines
6.4 KiB
JavaScript
120 lines
6.4 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://127.0.0.1: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://127.0.0.1' }), /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://127.0.0.1:3000' }])
|
|
const store = await FileConfigStore.open({ configPath, examplePath })
|
|
const calls = []
|
|
const registry = new ClientRegistry({ createClient: instance => fakeClient(instance, calls) }).reconcile(store.snapshot.instances)
|
|
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false })
|
|
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://example.test' } })
|
|
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://127.0.0.1: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' })
|
|
})
|