[verified] refactor: harden operations and redesign device console

This commit is contained in:
chick
2026-07-15 16:33:16 +08:00
parent e04a16e817
commit 4977afdc20
24 changed files with 769 additions and 779 deletions
+7 -6
View File
@@ -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' })
})
+14 -13
View File
@@ -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')
+182
View File
@@ -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)
})
+90
View File
@@ -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 -4
View File
@@ -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')