[verified] refactor: harden operations and redesign device console
This commit is contained in:
@@ -39,7 +39,7 @@ test('FileConfigStore uses example only as template and commits immutable snapsh
|
||||
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' })
|
||||
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)
|
||||
})
|
||||
@@ -48,7 +48,7 @@ test('failed persistence does not publish memory and transactions serialize', as
|
||||
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/)
|
||||
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))
|
||||
@@ -76,16 +76,17 @@ test('proxy path and target URL contracts reject origin escape vectors', () => {
|
||||
})
|
||||
|
||||
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 { 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 app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false })
|
||||
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://example.test' } })
|
||||
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')
|
||||
@@ -104,7 +105,7 @@ test('buildApp supports inject without listening and covers health/readiness/con
|
||||
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.equal(new URL(calls[0].url).origin, 'http://192.0.2.10:3000')
|
||||
assert.deepEqual((await app.inject('/api/missing')).json(), { error: 'not found' })
|
||||
})
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ test('proxy path rejects normalization and repeated-decoding structure changes',
|
||||
|
||||
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' } })
|
||||
const store = await FileConfigStore.open({ configPath, examplePath, env: { HOST: 'localhost', PORT: '4321' } })
|
||||
await Promise.all([
|
||||
store.add({ id: 'a', url: 'http://a.test' }),
|
||||
store.add({ id: 'b', url: 'http://b.test' }),
|
||||
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, 'env-host')
|
||||
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'])
|
||||
})
|
||||
@@ -59,24 +59,24 @@ test('atomicWriteJson replaces content, leaves no temp and creates POSIX 0600',
|
||||
|
||||
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/)
|
||||
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://old.test', auth: { mode: 'none', password: '' } }
|
||||
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://staged.test' }, { id: 'bad', url: 'http://bad.test' }]), /boom/)
|
||||
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://sim.test' }])
|
||||
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
|
||||
@@ -92,7 +92,7 @@ test('temporary login credential is ephemeral, saved flag comes from store, logo
|
||||
})
|
||||
|
||||
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 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
|
||||
@@ -109,14 +109,15 @@ test('failed temporary login credential is not retained for later automatic atte
|
||||
})
|
||||
|
||||
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 { 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 app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
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')
|
||||
@@ -132,7 +133,7 @@ test('committed config survives reconcile failure and readiness degrades then re
|
||||
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' } })
|
||||
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))
|
||||
@@ -141,7 +142,7 @@ test('committed config survives reconcile failure and readiness degrades then re
|
||||
|
||||
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 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')
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { normalizeConfig } from '../server/config/schema.js'
|
||||
import { FileConfigStore } from '../server/config/file-config-store.js'
|
||||
import { ClientRegistry } from '../server/clients/client-registry.js'
|
||||
import { buildApp } from '../server/app.js'
|
||||
import { createProxyPolicy } from '../server/proxy/policy.js'
|
||||
import { ConfirmationStore } from '../server/proxy/confirmations.js'
|
||||
|
||||
async function storeFor(instances = [{ id: 'one', url: 'http://192.168.8.1', auth: { password: 'old-secret' } }]) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-'))
|
||||
const configPath = path.join(dir, 'config.json')
|
||||
const examplePath = path.join(dir, 'example.json')
|
||||
await writeFile(examplePath, JSON.stringify({ instances }))
|
||||
return FileConfigStore.open({ configPath, examplePath, env: {} })
|
||||
}
|
||||
|
||||
function fakeRegistry(store, calls = []) {
|
||||
return new ClientRegistry({ createClient: instance => ({
|
||||
instance, jar: { clear() {} }, clearEphemeralSecret() {},
|
||||
fetchJson: async () => ({ ok: true, status: 200, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }),
|
||||
request: async (url, options) => { calls.push({ url: String(url), options }); return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) },
|
||||
}) }).reconcile(store.snapshot.instances)
|
||||
}
|
||||
|
||||
test('listen schema defaults to loopback and rejects every non-loopback host', () => {
|
||||
assert.equal(normalizeConfig({}, {}).server.host, '127.0.0.1')
|
||||
for (const host of ['0.0.0.0', '192.168.1.2', 'example.com', '::', '169.254.1.1']) {
|
||||
assert.throws(() => normalizeConfig({ server: { host } }, {}), /loopback/i)
|
||||
}
|
||||
for (const host of ['localhost', '127.0.0.2', '127.255.255.255', '::1', '[::1]']) {
|
||||
assert.equal(normalizeConfig({ server: { host } }, {}).server.host, host)
|
||||
}
|
||||
})
|
||||
|
||||
test('instance targets allow LAN but reject credentials and local/metadata targets', () => {
|
||||
assert.equal(normalizeConfig({ instances: [{ id: 'lan', url: 'http://192.168.1.1' }] }, {}).instances[0].url, 'http://192.168.1.1')
|
||||
for (const url of [
|
||||
'http://169.254.169.254/latest', 'http://169.254.1.1', 'http://127.0.0.1',
|
||||
'http://localhost', 'http://device.example.test', 'http://[::1]', 'http://[fe80::1]', 'http://[fe90::1]',
|
||||
'http://[::ffff:127.0.0.1]', 'http://[::ffff:169.254.169.254]', 'http://0x7f000001',
|
||||
'http://2130706433', 'http://100.100.100.200/latest/meta-data',
|
||||
'http://[64:ff9b::7f00:1]', 'http://[64:ff9b::a9fe:a9fe]',
|
||||
'http://168.63.129.16', 'http://[2002:7f00:1::]',
|
||||
'http://user:***@192.168.1.1',
|
||||
]) {
|
||||
assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url }] }, {}), /credentials|target/i)
|
||||
}
|
||||
})
|
||||
|
||||
test('control plane rejects non-loopback Host and cross-origin browser requests', async t => {
|
||||
const store = await storeFor(); const registry = fakeRegistry(store)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788' } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:1' } })).statusCode, 421)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: 'localhost:8788' } })).statusCode, 421)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: 'attacker.example' } })).statusCode, 421)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]evil.example' } })).statusCode, 421)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:bad:evil' } })).statusCode, 421)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://attacker.example' } })).statusCode, 403)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:9999' } })).statusCode, 403)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://127.0.0.1:8788' } })).statusCode, 403)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:8788' } })).statusCode, 200)
|
||||
})
|
||||
|
||||
test('expanded IPv6 loopback config accepts equivalent exact authorities', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-v6-'))
|
||||
const configPath = path.join(dir, 'config.json'), examplePath = path.join(dir, 'example.json')
|
||||
await writeFile(examplePath, JSON.stringify({ server: { host: '0:0:0:0:0:0:0:1', port: 8788 }, instances: [{ id: 'one', url: 'http://192.168.8.1' }] }))
|
||||
const store = await FileConfigStore.open({ configPath, examplePath, env: {} })
|
||||
const registry = fakeRegistry(store)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8788' } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[::1]:8788' } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8789' } })).statusCode, 421)
|
||||
})
|
||||
|
||||
test('proxy failures return a stable error without leaking upstream exception details', async t => {
|
||||
const store = await storeFor()
|
||||
const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw new Error('SECRET_UPSTREAM_DETAIL') } }) }).reconcile(store.snapshot.instances)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
const response = await app.inject({ url: '/api/proxy/one/api/device' })
|
||||
assert.equal(response.statusCode, 502)
|
||||
assert.equal(response.body.includes('SECRET_UPSTREAM_DETAIL'), false)
|
||||
})
|
||||
|
||||
test('upstream cannot impersonate controlled proxy errors to leak details', async t => {
|
||||
const store = await storeFor()
|
||||
for (const error of [new Error('SECRET invalid proxy path DETAIL'), Object.assign(new Error('SECRET_UNSUPPORTED_DETAIL'), { statusCode: 415 })]) {
|
||||
const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw error } }) }).reconcile(store.snapshot.instances)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
const response = await app.inject({ url: '/api/proxy/one/api/device' })
|
||||
assert.equal(response.statusCode, 502)
|
||||
assert.equal(response.body.includes('SECRET'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('confirmation store bounds pending tokens and invalidates a token on first consume attempt', () => {
|
||||
const store = new ConfirmationStore({ maxEntries: 2, ttlMs: 1000, now: () => 0 })
|
||||
const first = store.prepare({ id: 1 }).token
|
||||
store.prepare({ id: 2 }); store.prepare({ id: 3 })
|
||||
assert.equal(store.consume(first, { id: 1 }), false)
|
||||
const token = store.prepare({ id: 4 }).token
|
||||
assert.equal(store.consume(token, { id: 999 }), false)
|
||||
assert.equal(store.consume(token, { id: 4 }), false)
|
||||
})
|
||||
|
||||
test('password update has preserve/set/clear semantics and rejects legacy empty password', async () => {
|
||||
const store = await storeFor()
|
||||
assert.equal(store.snapshot.revision, 0)
|
||||
await store.update('one', { name: 'renamed', passwordAction: 'preserve' })
|
||||
assert.equal(store.snapshot.instances[0].name, 'renamed')
|
||||
assert.equal(store.snapshot.instances[0].auth.password, 'old-secret')
|
||||
assert.equal(store.snapshot.revision, 1)
|
||||
await store.update('one', { name: 'changed' })
|
||||
assert.equal(store.snapshot.instances[0].auth.password, 'old-secret')
|
||||
assert.equal(store.snapshot.revision, 2)
|
||||
await assert.rejects(store.update('one', { auth: { password: '' } }), /ambiguous/i)
|
||||
await assert.rejects(store.update('one', { passwordAction: 'set', password: '' }), /non-empty/i)
|
||||
await store.update('one', { passwordAction: 'set', password: 'new-secret' })
|
||||
assert.equal(store.snapshot.instances[0].auth.password, 'new-secret')
|
||||
await store.update('one', { passwordAction: 'clear' })
|
||||
assert.equal(store.snapshot.instances[0].auth.password, '')
|
||||
})
|
||||
|
||||
test('metadata update API never returns passwords and legacy empty password is 400', async t => {
|
||||
const store = await storeFor(); const registry = fakeRegistry(store)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
const bad = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { auth: { password: '' } } })
|
||||
assert.equal(bad.statusCode, 400); assert.match(bad.json().error, /ambiguous/i)
|
||||
const good = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { name: 'safe' } })
|
||||
assert.equal(good.statusCode, 200); assert.equal(JSON.stringify(good.json()).includes('old-secret'), false)
|
||||
})
|
||||
|
||||
test('production proxy policy rejects unknown/auth paths and disallowed methods before upstream', async t => {
|
||||
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close())
|
||||
assert.equal((await app.inject('/api/proxy/one/api/device')).statusCode, 200)
|
||||
assert.equal((await app.inject('/api/proxy/one/api/x')).statusCode, 403)
|
||||
assert.equal((await app.inject('/api/proxy/one/api/auth/login')).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/device' })).statusCode, 405)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('injectable policy can allow fake paths while dangerous writes require bound one-use confirmation', async t => {
|
||||
let now = 1000
|
||||
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
|
||||
const policy = createProxyPolicy({ readPaths: ['/api/x'], writePaths: { '/api/x': ['POST'] } })
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy, confirmationOptions: { now: () => now, ttlMs: 100 } }); t.after(() => app.close())
|
||||
|
||||
const missing = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', payload: { value: 1 } })
|
||||
assert.equal(missing.statusCode, 428); assert.equal(calls.length, 0)
|
||||
const prepared = await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })
|
||||
assert.equal(prepared.statusCode, 200); const token = prepared.json().token
|
||||
const tampered = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', headers: { 'x-confirmation-token': token }, payload: { value: 2 } })
|
||||
assert.equal(tampered.statusCode, 403); assert.equal(calls.length, 0)
|
||||
const okToken = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })).json().token
|
||||
const ok = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } })
|
||||
assert.equal(ok.statusCode, 200); assert.equal(calls.length, 1)
|
||||
const replay = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } })
|
||||
assert.equal(replay.statusCode, 403); assert.equal(calls.length, 1)
|
||||
|
||||
const expiring = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: null } })).json().token
|
||||
now += 101
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': expiring }, payload: null })).statusCode, 403)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test('confirmation is invalidated by target revision/origin change', async t => {
|
||||
const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls)
|
||||
const policy = createProxyPolicy({ writePaths: { '/api/x': ['POST'] } })
|
||||
const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy }); t.after(() => app.close())
|
||||
const token = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: {} } })).json().token
|
||||
await store.update('one', { name: 'revision bump' })
|
||||
const response = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': token }, payload: {} })
|
||||
assert.equal(response.statusCode, 403); assert.equal(calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { filterAndSortFleet, emptyFleetReason } from '../public/state/fleet-view-model.js'
|
||||
import { instanceDraftPayload, requestDraft } from '../public/state/operation-draft.js'
|
||||
import { resolveView } from '../public/router/view-router.js'
|
||||
|
||||
const root = new URL('../', import.meta.url)
|
||||
const text = path => readFile(new URL(path, root), 'utf8')
|
||||
|
||||
test('fleet view model filters, searches and sorts without mutating source', () => {
|
||||
const source = [{ id: 'z', name: 'Zulu', url: 'http://z', tags: ['备用'] }, { id: 'a', name: 'Alpha', url: 'http://a', tags: [] }]
|
||||
const statuses = new Map([['z', { reachable: false, latencyMs: 80 }], ['a', { reachable: true, authenticated: true, latencyMs: 12 }]])
|
||||
assert.deepEqual(filterAndSortFleet(source, statuses, { filter: 'online', sort: 'latency' }).map(x => x.id), ['a'])
|
||||
assert.deepEqual(filterAndSortFleet(source, statuses, { query: '备用', sort: 'name' }).map(x => x.id), ['z'])
|
||||
assert.deepEqual(source.map(x => x.id), ['z', 'a'])
|
||||
assert.equal(emptyFleetReason({ total: 0 }), 'config')
|
||||
assert.equal(emptyFleetReason({ total: 2, query: 'x', visible: 0 }), 'search')
|
||||
assert.equal(emptyFleetReason({ total: 2, filter: 'offline', visible: 0 }), 'filter')
|
||||
})
|
||||
|
||||
test('operation drafts enforce explicit password and request semantics', () => {
|
||||
assert.deepEqual(instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'preserve' }, true).passwordAction, 'preserve')
|
||||
assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'set', password: '' }, true), /密码/)
|
||||
assert.throws(() => instanceDraftPayload({ id: 'one', name: 'One', url: 'http://one', passwordAction: 'clear' }, true), /确认/)
|
||||
assert.deepEqual(requestDraft('GET', '/api/device', { ignored: true }), { method: 'GET', path: '/api/device' })
|
||||
assert.equal(requestDraft('PATCH', '/api/data', { enabled: true }).body.enabled, true)
|
||||
})
|
||||
|
||||
test('protected views fall back home without an active device', () => {
|
||||
for (const view of ['detail', 'rawpage', 'api']) assert.equal(resolveView(view, false), 'home')
|
||||
assert.equal(resolveView('rawpage', true), 'rawpage')
|
||||
})
|
||||
|
||||
test('phase two shell, CRUD, API console and accessibility contracts exist', async () => {
|
||||
const [html, app, css, responsive, api] = await Promise.all([
|
||||
text('public/index.html'), text('public/app.js'), text('public/styles/components.css'), text('public/styles/responsive.css'), text('public/infrastructure/api-client.js'),
|
||||
])
|
||||
assert.doesNotMatch(html, /fonts\.googleapis|fonts\.gstatic/)
|
||||
assert.match(html, /class="desktop-sidebar"/)
|
||||
assert.match(html, /class="mobile-nav"/)
|
||||
assert.match(html, /aria-live="polite"[^>]*role="status"/)
|
||||
assert.match(html, /id="fatalState"[^>]*role="alert"/)
|
||||
assert.match(html, /id="persistentError"[^>]*role="alert"/)
|
||||
assert.match(html, /id="deleteDialog"[\s\S]*aria-labelledby="deleteDialogTitle"/)
|
||||
assert.match(html, /id="apiDeleteDialog"[\s\S]*aria-labelledby="apiDeleteTitle"/)
|
||||
assert.match(html, /id="passwordPreserve"[\s\S]*id="passwordSet"[\s\S]*id="passwordClear"/)
|
||||
assert.match(html, /id="endpointSearch"/)
|
||||
assert.match(html, /id="outputPretty"[\s\S]*id="outputRaw"[\s\S]*id="copyOutput"/)
|
||||
assert.match(html, /<option>PUT<\/option>[\s\S]*<option>PATCH<\/option>/)
|
||||
assert.match(html, /rel="noopener noreferrer"/)
|
||||
|
||||
assert.doesNotMatch(app, /\bconfirm\s*\(/)
|
||||
assert.doesNotMatch(app, /role="button"/)
|
||||
assert.match(app, /passwordAction/)
|
||||
assert.match(app, /prepareConfirmation/)
|
||||
assert.match(app, /x-confirmation-token/)
|
||||
assert.match(app, /lastSuccessfulRefresh/)
|
||||
assert.match(app, /requests\.isCurrent/)
|
||||
assert.match(app, /prepared[\s\S]*requests\.isCurrent\('api-console'/)
|
||||
assert.match(app, /try\s*\{[\s\S]*instanceDraftPayload/)
|
||||
assert.match(app, /try\s*\{[\s\S]*parseBody/)
|
||||
assert.match(app, /clearPasswordConfirm'\)\.checked=false/)
|
||||
assert.match(app, /\[data-dialog-cancel\]/)
|
||||
assert.match(app, /instanceDialogEpoch\s*\+=\s*1/)
|
||||
assert.match(app, /deleteDialogEpoch\s*\+=\s*1/)
|
||||
assert.match(app, /loginDialogEpoch\s*\+=\s*1/)
|
||||
assert.match(app, /if\s*\(!result\.value\.authenticated\)/)
|
||||
assert.match(app, /pendingApiRequest/)
|
||||
assert.match(app, /isWriteMethod\(method\)[\s\S]*apiDeleteDialog'\)\.showModal/)
|
||||
assert.match(html, /id="apiDeleteDetails"[\s\S]*id="apiDeleteBody"/)
|
||||
assert.match(app, /passwordInput'\)\.value=''/)
|
||||
assert.match(app, /loginError'\)\.hidden=true/)
|
||||
assert.match(app, /loginBtn'\)\.addEventListener[\s\S]*submitLogin'\)\.disabled=false/)
|
||||
assert.match(app, /function openDeviceDetail[\s\S]*submitLogin'\)\.disabled=false/)
|
||||
assert.match(app, /function setActiveHome[\s\S]*submitLogin'\)\.disabled=false/)
|
||||
assert.match(app, /document\.title/)
|
||||
assert.match(app, /removeAttribute\('src'\)/)
|
||||
|
||||
assert.doesNotMatch(css, /minmax\(340px/)
|
||||
assert.doesNotMatch(css, /\.island-card\{[^}]*box-shadow/s)
|
||||
assert.doesNotMatch(css, /text-overflow\s*:\s*ellipsis|white-space\s*:\s*nowrap/)
|
||||
assert.match(css, /overflow-wrap:break-word/)
|
||||
assert.match(responsive, /max-width:767px/)
|
||||
assert.match(responsive, /min-width:768px/)
|
||||
assert.match(responsive, /min-width:1024px/)
|
||||
assert.match(responsive, /min-width:1440px/)
|
||||
assert.match(responsive, /min-height:44px/)
|
||||
assert.match(api, /proxy-confirmations\/prepare/)
|
||||
})
|
||||
@@ -4,11 +4,11 @@ import assert from 'node:assert/strict'
|
||||
import { createSimAdminClient, normalizeInstance, summarizeInstanceSnapshot, redactInstance } from '../server/core.js'
|
||||
|
||||
test('normalizeInstance supports passwordless and password-protected instances without exposing password', () => {
|
||||
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://127.0.0.1:3000/' }, 0)
|
||||
const open = normalizeInstance({ id: 'open-1', name: '开放设备', url: 'http://192.0.2.12:3000/' }, 0)
|
||||
assert.equal(open.auth.mode, 'none')
|
||||
assert.equal(open.url, 'http://127.0.0.1:3000')
|
||||
assert.equal(open.url, 'http://192.0.2.12:3000')
|
||||
|
||||
const protectedOne = normalizeInstance({ id: 'locked', url: 'http://cpe.local', auth: { password: 'secret' } }, 1)
|
||||
const protectedOne = normalizeInstance({ id: 'locked', url: 'http://192.0.2.13', auth: { password: 'secret' } }, 1)
|
||||
assert.equal(protectedOne.auth.mode, 'password')
|
||||
assert.equal(protectedOne.auth.password, 'secret')
|
||||
assert.equal(redactInstance(protectedOne).auth.hasPassword, true)
|
||||
@@ -30,7 +30,7 @@ test('client stores simadmin_session from login and sends it on later proxied re
|
||||
}
|
||||
return new Response('{}', { status: 404 })
|
||||
}
|
||||
const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://sim.local', auth: { password: 'secret' } }, 0), { fetchImpl })
|
||||
const client = createSimAdminClient(normalizeInstance({ id: 'locked', url: 'http://192.0.2.14', auth: { password: 'secret' } }, 0), { fetchImpl })
|
||||
const auth = await client.ensureAuthenticated()
|
||||
assert.equal(auth.authenticated, true)
|
||||
const proxied = await client.fetchJson('/api/device')
|
||||
|
||||
Reference in New Issue
Block a user