Files
MikrotikManager/backend/src/modules/mcp/flow-host-keys-http.test.ts
T

151 lines
12 KiB
TypeScript

import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { setTimeout as delay } from "node:timers/promises"
import type { Pool } from "pg"
const endpoint = new URL(process.env.DATABASE_URL ?? "postgresql://invalid")
assert.equal(process.env.MMAPP_OWN_FLOW_HOST_KEYS, "true")
assert.equal(endpoint.hostname, "127.0.0.1"); assert.equal(endpoint.pathname, "/postgres")
assert.match(process.env.MCP_STATE_DIR ?? "", /mm-mcp-test-/)
process.env.AUTH_REQUIRED = "true"; process.env.AUTH_ISSUER = "https://auth.test.local"
const { pool, closePool } = await import("../../db/index.js")
const { applySqlMigrations } = await import("../../db/migrate.js")
const { createFlowSettingsStore } = await import("../../services/traffic-flow-settings-store.js")
const { generateFlowHostKeys, flowSettingsPrivateProof } = await import("../../services/traffic-flow-host-keys-service.js")
const { invalidateTrafficFlowSettingsCache } = await import("../../services/traffic-flow-settings.js")
const { withFlowConfigurationLock } = await import("../../services/traffic-flow-host-agent.js")
const { withApplicationMutationLock } = await import("../../services/config-mutation-lock.js")
const { getFlowListenerState, getFlowWorkerHealth } = await import("../../services/traffic-flow-ingest.js")
const { buildApp } = await import("../../index.js")
const { env } = await import("../../config.js")
const { createMcpConnection, authenticateMcpToken, updateMcpSettings, transaction } = await import("./store.js")
const { invokeMcpTool, approvePlan, applyPlan } = await import("./runtime.js")
const { decryptSecret } = await import("./security.js")
const actor = { id: "flow-keys-fixture-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:traffic:admin"], expiresAt: Date.now() + 3600000 }
let app: Awaited<ReturnType<typeof buildApp>> | undefined
let network = 0
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => { network++; throw Error("Isolated key generation prohibits network") }) as typeof fetch
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await pool.query("ALTER TABLE traffic_flow_settings ADD COLUMN fixture_future jsonb NOT NULL DEFAULT '{\"opaque\":true}'::jsonb")
const store = createFlowSettingsStore(pool, async () => {}, invalidateTrafficFlowSettingsCache)
{
const before = await store.read(), receiptId = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(receiptId, "configuration", proof)
await assert.rejects(store.claim(receiptId, "configuration", proof))
await pool.query("UPDATE traffic_flow_settings SET packets_received=111,last_error='telemetry',updated_at=now() WHERE id=1")
assert.deepEqual(await store.read(), before)
const desired = { ...before, hostPublicKey: "fixture-public", hostPrivateKey: "fixture-private" }
assert.equal(await store.compareAndSet(receiptId, proof, before, desired), true)
assert.deepEqual(await store.read(), desired)
assert.equal((await pool.query("SELECT packets_received FROM traffic_flow_settings WHERE id=1")).rows[0].packets_received, 111)
await assert.rejects(store.compareAndSet(receiptId, proof, before, desired))
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
await pool.query("UPDATE traffic_flow_settings SET fixture_future='{\"opaque\":false}' WHERE id=1")
assert.equal(await store.compareAndSet(id, proof, before, { ...before, hostPublicKey: "denied" }), false)
assert.notEqual((await store.read()).hostPublicKey, "denied")
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
let commitAttempts = 0, quarantined = false
const lost = createFlowSettingsStore({ query: pool.query.bind(pool), connect: async () => {
const client = await pool.connect()
return { query: async (sql: string, values?: unknown[]) => {
const result = await client.query(sql, values)
if (sql === "COMMIT") { commitAttempts++; throw Error("synthetic lost commit ACK") }
return result
}, release: (unsafe?: boolean) => { quarantined = Boolean(unsafe); client.release(unsafe) } }
} } as unknown as Pick<Pool, "connect" | "query">, async () => {}, invalidateTrafficFlowSettingsCache)
await assert.rejects(lost.compareAndSet(id, proof, before, { ...before, hostPublicKey: "lost-ack-public" }))
assert.equal(commitAttempts, 1); assert.equal(quarantined, true)
assert.equal((await store.read()).hostPublicKey, "lost-ack-public")
await assert.rejects(store.claim(id, "configuration", proof))
}
{
const before = await store.read(), proof = await flowSettingsPrivateProof(before), attempts = [randomUUID(), randomUUID()]
await Promise.all(attempts.map(id => store.claim(id, "configuration", proof)))
const raced = await Promise.all(attempts.map((id, index) => store.compareAndSet(id, proof, before, { ...before, publicEndpoint: `fixture-${index}` })))
assert.equal(raced.filter(Boolean).length, 1, "full CAS admits only one concurrent different update")
}
{
const before = await store.read(), id = randomUUID(), proof = await flowSettingsPrivateProof(before)
await store.claim(id, "configuration", proof)
let revoked = false
const denied = createFlowSettingsStore({ query: pool.query.bind(pool), connect: async () => {
const client = await pool.connect()
return { query: async (sql: string, values?: unknown[]) => {
const result = await client.query(sql, values)
if (sql.startsWith("UPDATE traffic_flow_settings")) revoked = true
return result
}, release: (unsafe?: boolean) => client.release(unsafe) }
} } as unknown as Pick<Pool, "connect" | "query">, async () => { if (revoked) throw Error("synthetic revoke after UPDATE") }, invalidateTrafficFlowSettingsCache)
await assert.rejects(denied.compareAndSet(id, proof, before, { ...before, hostPublicKey: "must-rollback" }))
assert.deepEqual(await store.read(), before)
assert.equal((await pool.query("SELECT state FROM flow_settings_intents WHERE receipt_id=$1", [id])).rows[0].state, "claimed")
await assert.rejects(store.claim(id, "configuration", proof))
}
const listener = getFlowListenerState()
for (const [publicKey, privateKey] of [["", ""], ["", "synthetic-existing-private"], ["synthetic-existing-public", ""], ["", ""]]) {
await pool.query("UPDATE traffic_flow_settings SET host_public_key=$1,host_private_key=$2 WHERE id=1", [publicKey, privateKey]); invalidateTrafficFlowSettingsCache()
const before = await store.read(), first = await generateFlowHostKeys()
assert.equal(first.created, true); assert.match(first.publicKey, /^[A-Za-z0-9+/]{43}=$/)
const after = await store.read()
assert.deepEqual({ ...after, hostPublicKey: before.hostPublicKey, hostPrivateKey: before.hostPrivateKey }, before)
const second = await withApplicationMutationLock(() => withFlowConfigurationLock(() => generateFlowHostKeys()))
assert.equal(second.created, false); assert.equal(second.publicKey, first.publicKey)
assert.deepEqual(await store.read(), after)
assert.ok(!JSON.stringify(first).includes(after.hostPrivateKey))
}
const vrfTickets = await (await import("../../services/vrf-production-ticket-store-sql.test.js")).runVrfProductionTicketSqlAcceptance({ sql: pool, transaction, claimConnections: { connect: () => pool.connect() } })
assert.equal(vrfTickets.passed, true)
console.log(`VRF production tickets SQL: ${vrfTickets.cases} actual one-use/owner/source/expiry/concurrency/unknown claims; no native execution grant`)
await updateMcpSettings({ enabled: true }, actor)
app = await buildApp({ startScheduler: false }); await app.ready()
const access = await createMcpConnection({ name: "host keys acceptance", mode: "autonomous", permissions: ["mm:traffic:write"], expiresAt: new Date(Date.now() + 600000).toISOString(), scope: { allowGlobal: true, serverIds: [], operationIds: ["traffic.flow.settings.generate-keys"] }, limits: { callsPerMinute: 240, mutationsPerHour: 1000 } }, actor)
const principal = await authenticateMcpToken(access.token)
const wait = async (id: string) => {
for (const deadline = Date.now() + 45000; Date.now() < deadline;) {
const row = (await pool.query("SELECT state,verification,error_code FROM mcp_operations WHERE id=$1", [id])).rows[0]
if (!["pending", "running"].includes(row.state)) return row
await delay(20)
}
throw Error("Operation still live, do not replay")
}
await pool.query("UPDATE traffic_flow_settings SET host_public_key='',host_private_key='' WHERE id=1"); invalidateTrafficFlowSettingsCache()
const planned = await invokeMcpTool(principal, "mm_netflow_host_keys_generate_plan", {}) as { planId: string; state: string; changes: unknown }
assert.equal(planned.state, "awaiting-approval")
const evidence = JSON.parse(await decryptSecret((await pool.query("SELECT evidence FROM mcp_plans WHERE id=$1", [planned.planId])).rows[0].evidence.sealed))
assert.equal(evidence.requiresHuman, true); assert.equal(evidence.mutationCost, 1)
await assert.rejects(applyPlan(principal, planned.planId, randomUUID()))
await approvePlan(planned.planId, actor, true)
const operation = await applyPlan(principal, planned.planId, randomUUID()), finished = await wait(operation.id)
assert.equal(finished.state, "applied", JSON.stringify(finished)); assert.equal(finished.verification, "passed")
const result = (await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1", [operation.id])).rows[0].result
const current = await store.read()
assert.equal(result.publicKey, current.hostPublicKey); assert.equal(result.created, true)
assert.ok(!JSON.stringify({ planned, result }).includes(current.hostPrivateKey))
assert.equal((await pool.query("SELECT count(*)::int AS n FROM flow_settings_intents WHERE receipt_id=$1", [result.receiptId])).rows[0].n, 1)
await assert.rejects(generateFlowHostKeys({ receiptId: result.receiptId }))
const jwt = app.jwt.sign({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }, { iss: env.authIssuer, expiresIn: "10m" })
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys", headers: { authorization: "Bearer " + jwt } })
assert.equal(response.statusCode, 200, response.body)
assert.equal(response.json().created, false); assert.equal(response.json().publicKey, result.publicKey)
assert.ok(!response.body.includes(current.hostPrivateKey))
assert.equal((await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys" })).statusCode, 401)
const protectedId = randomUUID()
await pool.query("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2,$3)", [protectedId, { kind: "netflow-settings", id: "1", protection: "deny" }, actor.id])
const denied = await app.inject({ method: "POST", url: "/api/traffic/flow/settings/generate-keys", headers: { authorization: "Bearer " + jwt } })
assert.equal(denied.statusCode, 409, denied.body)
await assert.rejects(invokeMcpTool(principal, "mm_netflow_host_keys_generate_plan", {}))
await pool.query("DELETE FROM mcp_protections WHERE id=$1", [protectedId])
assert.equal(network, 0); assert.equal(getFlowWorkerHealth().alive, false); assert.deepEqual(getFlowListenerState(), listener)
await (await import("./flow-settings-acceptance.js")).acceptMcpFlowSettingsUpdate(actor, app)
console.log("NetFlow keys SQL/HTTP: complete config CAS incl future columns; telemetry separation; unique claims; lost COMMIT no replay; real generation, reentrant shared locks; mandatory MCP human and private binding; JWT REST parity/protection; zero collector/network")
} finally { globalThis.fetch = originalFetch; await app?.close(); await closePool() }