import test from 'node:test' import assert from 'node:assert/strict' import { mkdtemp, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { FileConfigStore, atomicWriteJson } from '../server/config/file-config-store.js' import { normalizeConfig } from '../server/config/schema.js' import { ClientRegistry } from '../server/clients/client-registry.js' import { safeProxyPath, proxyHeaders } from '../server/proxy/service.js' import { createSimAdminClient } from '../server/core.js' import { collectInstanceStatus } from '../server/status/service.js' import { buildApp } from '../server/app.js' import { RequestCoordinator } from '../public/state/request-coordinator.js' import { createApiClient } from '../public/infrastructure/api-client.js' import { createViewRouter } from '../public/router/view-router.js' const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') async function files(instances = []) { const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-')) const configPath = path.join(dir, 'config.json') const examplePath = path.join(dir, 'example.json') await writeFile(examplePath, JSON.stringify({ server: { port: 8788 }, instances })) return { dir, configPath, examplePath } } function client(instance, request) { return { instance, request, jar: { clear() {} }, fetchJson: async () => ({ ok: true, status: 200, latencyMs: 1, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }) } } test('proxy path rejects normalization and repeated-decoding structure changes', () => { assert.equal(safeProxyPath('api/a%20b'), '/api/a%20b') for (const input of ['%252e%252e/x', 'api/%252f/x', 'api/%255c/x', 'api/a%2525252fetc', 'api/%2525252e%2525252e/x', 'api/%2525253fx', 'api\\x', '//evil/x', '%2f%2fevil/x', 'api/%3fadmin', 'api/%253fadmin', 'api/%23x', 'api/%2523x', 'api/%2e%2e/x']) { assert.throws(() => safeProxyPath(input), /invalid proxy path/, input) } assert.equal(proxyHeaders({ 'accept-encoding': 'gzip', accept: 'json' }).has('accept-encoding'), false) }) test('FileConfigStore preserves constructor env across transactions and serializes real writes', async () => { const { configPath, examplePath } = await files() const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'localhost', PORT: '4321' } }) await Promise.all([ store.add({ id: 'a', url: 'http://192.0.2.21' }), store.add({ id: 'b', url: 'http://192.0.2.22' }), ]) assert.equal(store.snapshot.server.host, 'localhost') assert.equal(store.snapshot.server.port, 4321) assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')).instances.map(x => x.id), ['a', 'b']) }) test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600', async () => { const { dir, configPath } = await files() await writeFile(configPath, 'old', { mode: 0o644 }) await atomicWriteJson(configPath, { ok: true }) assert.deepEqual(JSON.parse(await readFile(configPath, 'utf8')), { ok: true }) assert.deepEqual((await readdir(dir)).filter(x => x.endsWith('.tmp')), []) if (process.platform !== 'win32') assert.equal((await stat(configPath)).mode & 0o777, 0o600) }) test('schema rejects invalid port and auth mode', () => { for (const port of [0, 65536, 1.5, 'wat']) assert.throws(() => normalizeConfig({ server: { port }, instances: [] }, {}), /port/) assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url: 'http://192.0.2.27', auth: { mode: 'token' } }] }, {}), /auth.mode/) }) test('registry reconcile builds next map before publishing and closes staged clients on create failure', () => { const old = { id: 'old', url: 'http://192.0.2.24', auth: { mode: 'none', password: '' } } let stagedClosed = false const registry = new ClientRegistry({ createClient: item => { if (item.id === 'bad') throw new Error('boom') return { instance: item, close() { if (item.id === 'staged') stagedClosed = true } } } }).reconcile([old]) assert.throws(() => registry.reconcile([{ id: 'staged', url: 'http://192.0.2.26' }, { id: 'bad', url: 'http://192.0.2.23' }]), /boom/) assert.equal(stagedClosed, true) assert.equal(registry.get('old').instance.id, 'old') assert.equal(registry.get('bad'), undefined) }) test('temporary login credential is ephemeral, saved flag comes from store, logout clears it', async t => { const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25' }]) const store = await FileConfigStore.open({ configPath, examplePath }) let credential let cleared = false const fake = { instance: structuredClone(store.snapshot.instances[0]), jar: { clear() {} }, ensureAuthenticated: async options => { credential = options?.credential; return { authenticated: true } }, clearEphemeralSecret: () => { cleared = true }, fetchJson: async () => ({ ok: true }) } const registry = new ClientRegistry({ createClient: () => fake }).reconcile(store.snapshot.instances) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) const login = await app.inject({ method: 'POST', url: '/api/instances/one/login', payload: { password: 'temporary' } }) assert.equal(credential, 'temporary') assert.equal(fake.instance.auth.password, '') assert.equal(login.json().hasSavedPassword, false) await app.inject({ method: 'POST', url: '/api/instances/one/logout' }) assert.equal(cleared, true) }) test('failed temporary login credential is not retained for later automatic attempts', async () => { const instance = { id: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } } const bodies = [] const c = createSimAdminClient(instance, { fetchImpl: async (url, options = {}) => { const pathname = new URL(url).pathname if (pathname === '/api/auth/status') return new Response(JSON.stringify({ configured: true, authenticated: false, settings: { password_protection_enabled: true } }), { status: 200 }) bodies.push(options.body) return new Response('{}', { status: 401 }) } }) const failed = await c.ensureAuthenticated({ credential: 'wrong' }) assert.equal(failed.authenticated, false) const later = await c.ensureAuthenticated() assert.equal(later.loginAttempted, false) assert.equal(later.reason, 'password_required') assert.deepEqual(bodies, ['{"password":"wrong"}']) }) test('proxy body/header contract handles JSON text buffer, gzip metadata and stable 415', async t => { const { configPath, examplePath } = await files([{ id: 'one', url: 'http://192.0.2.25/base' }]) const store = await FileConfigStore.open({ configPath, examplePath }) const calls = [] const registry = new ClientRegistry({ createClient: instance => client(instance, async (url, options) => { calls.push({ url: String(url), options }) return new Response('decoded', { status: 503, headers: { 'content-encoding': 'gzip', 'content-length': '999', 'x-upstream': 'yes' } }) }) }).reconcile(store.snapshot.instances) const testPolicy = { authorize: () => ({ allowed: true, dangerous: false }) } const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: testPolicy }); t.after(() => app.close()) const json = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?q=1', headers: { 'content-type': 'application/json', 'accept-encoding': 'gzip' }, payload: { x: 1 } }) assert.equal(json.statusCode, 503); assert.equal(calls[0].options.body, '{"x":1}'); assert.equal(new URL(calls[0].url).pathname, '/api/x'); assert.equal(new URL(calls[0].url).search, '?q=1') assert.equal(calls[0].options.headers.has('accept-encoding'), false); assert.equal(json.headers['content-encoding'], undefined); assert.equal(json.headers['content-length'], '7') await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'text/plain' }, payload: 'hello' }); assert.equal(calls[1].options.body, 'hello') await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'application/octet-stream' }, payload: Buffer.from('bin') }); assert.ok(Buffer.isBuffer(calls[2].options.body)) const empty = await app.inject({ method: 'POST', url: '/api/proxy/one/api/logout' }); assert.equal(empty.statusCode, 503); assert.equal(calls[3].options.body, undefined) const unsupported = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'content-type': 'multipart/form-data; boundary=x' }, payload: '--x--' }); assert.equal(unsupported.statusCode, 415); assert.deepEqual(unsupported.json(), { error: 'unsupported proxy content type' }) }) test('committed config survives reconcile failure and readiness degrades then retry recovers', async t => { const { configPath, examplePath } = await files() const store = await FileConfigStore.open({ configPath, examplePath }) let fail = true const registry = new ClientRegistry({ createClient: instance => { if (fail) throw new Error('factory'); return client(instance, async () => new Response('{}')) } }) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, reconcileRetryMs: 10 }); t.after(() => app.close()) const created = await app.inject({ method: 'POST', url: '/api/instances', payload: { id: 'x', url: 'http://192.0.2.27' } }) assert.equal(created.statusCode, 200); assert.equal(created.json().reconcilePending, true); assert.equal((await app.inject('/api/ready')).json().ready, false) fail = false await new Promise(resolve => setTimeout(resolve, 30)) assert.equal((await app.inject('/api/ready')).json().ready, true); assert.ok(registry.get('x')) }) test('status supports registration_status and health 404 with successful business endpoint', async () => { const values = new Map([['/api/health', { ok: false, status: 404, latencyMs: 2, data: null }], ['/api/auth/status', { ok: false, status: 404, data: null }], ['/api/device', { ok: true, status: 200, data: { model: 'X' } }], ['/api/network', { ok: true, status: 200, data: { registration_status: 'registered' } }]]) const instance = { id: 'x', name: 'x', url: 'http://192.0.2.27', auth: { mode: 'none', password: '' } } const c = createSimAdminClient(instance, { fetchImpl: async url => { const v = values.get(new URL(url).pathname) || { ok: false, status: 404, data: null }; return new Response(JSON.stringify(v.data), { status: v.status }) } }) const status = await collectInstanceStatus(c) assert.equal(status.reachable, true); assert.notEqual(status.authenticated, false); assert.equal(status.summary.network.registration, 'registered') }) test('bootstrap copies immutable listen options before handing them to Fastify', async () => { const source = await readFile(path.join(projectRoot, 'server/index.js'), 'utf8') assert.match(source, /app\.listen\(\{\s*\.\.\.configStore\.snapshot\.server\s*\}\)/) assert.doesNotMatch(source, /app\.listen\(configStore\.snapshot\.server\)/) }) test('coordinator structures rejection and stale errors; api client/router modules are usable', async () => { const coordinator = new RequestCoordinator(); let rejectOld const old = coordinator.run('login', 'a', () => new Promise((_, reject) => { rejectOld = reject })) assert.deepEqual(await coordinator.run('login', 'b', async () => 'ok'), { accepted: true, ownerId: 'b', value: 'ok' }) rejectOld(new Error('old')); const stale = await old assert.equal(stale.accepted, false); assert.equal(stale.ownerId, 'a'); assert.match(stale.error.message, /old/) const api = createApiClient({ fetchImpl: async () => new Response('{"ok":true}') }); assert.deepEqual(await api.config(), { ok: true }) const classes = () => ({ active: false, toggle(_name, value) { this.active = value } }); const control = { dataset: { view: 'home' }, classList: classes() }; const panel = { id: 'homeView', classList: classes() } createViewRouter({ controls: () => [control], panels: () => [panel], storage: null })('home'); assert.equal(control.classList.active, true); assert.equal(panel.classList.active, true) })