Files
multi-simadmin/test/phase-one-blockers.test.js

167 lines
12 KiB
JavaScript

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