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' }) })