166 lines
12 KiB
JavaScript
166 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: 'env-host', PORT: '4321' } })
|
|
await Promise.all([
|
|
store.add({ id: 'a', url: 'http://a.test' }),
|
|
store.add({ id: 'b', url: 'http://b.test' }),
|
|
])
|
|
assert.equal(store.snapshot.server.host, 'env-host')
|
|
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://x.test', 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://old.test', 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://staged.test' }, { id: 'bad', url: 'http://bad.test' }]), /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://sim.test' }])
|
|
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://x.test', 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://sim.test/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 app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); 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://x.test' } })
|
|
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://x.test', 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)
|
|
})
|