Files
MikrotikManager/backend/src/modules/mcp/server-probe-actions.test.ts
T

187 lines
21 KiB
TypeScript

import assert from "node:assert/strict"
import { createHash, randomUUID } from "node:crypto"
import { createServerProbeMcpAdapter, serverProbeActionSchema, serverProbeSafeMcpError, type ServerProbeMcpDependencies } from "./server-probe-actions.js"
import { ServerProbeExecutionError, type PreparedServerProbe, type ServerProbeAuthority, type ServerProbeReceipt, type ServerProbeJournal } from "../../services/server-probe-execution.js"
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
import { createServerProbeJournalHistoryReader } from "../../services/server-probe-journal-history.js"
import type { ActionContext, ChangeEvidence } from "./action-types.js"
import { fingerprint, redact } from "./security.js"
const now = 1800000000000, operationId = randomUUID(), connectionId = randomUUID(), credentialId = randomUUID(), receiptId = randomUUID()
const principal = { connectionId, credentialId, credentialExpiresAt: new Date(now + 600000).toISOString() }
const proof = async (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
const context = { principal, connection: { id: connectionId, owner_id: "owner", owner_claims_issued_at: new Date(now - 1000) } } as ActionContext
let assertions = 0
function fixture(retiredSource?: ServerProbeMcpDependencies["retiredSource"]) {
const events: string[] = [], receipts = new Map<string, ServerProbeReceipt>(), contexts: ServerProbeJournalContext[] = [], sealed = new Map<string, string>()
let evidence: ChangeEvidence, result: unknown = null, stepState = "intent", operationState = "running", scope = [2, 9], revoked = false, sqlTamper: string | undefined, dispatchFailure: unknown, preparedTool: PreparedServerProbe["tool"] = "ping", mismatchDestination = false, nextCipher = 0, revokeOnQuery = false, expiredPrepared = false, clock = now, liveHistory = true
const access: ServerProbeAuthority = { transport: "mcp", ownerId: "owner", connectionId, credentialId, expiresAt: now + 600000, generation: "generation", policyProof: "1".repeat(64), ownerProof: "2".repeat(64), active: true, operationAllowed: true, scopeServerIds: scope, ownerPermissions: ["mm:servers:write"], policyPermissions: ["mm:servers:write"] }
const readAuthority = async () => { events.push("authority"); if (revoked) throw new ServerProbeExecutionError("PERMISSION_DENIED", "PRIVATE_PROVIDER_SECRET", 403); return { ...access, expiresAt: clock + 600000, scopeServerIds: [...scope] } }
const assertProtections = async (ids: number[]) => { events.push("protections"); if (ids.some(id => !scope.includes(id))) throw new Error("out of scope") }
const seal = async (value: string) => { const id = `opaque-${++nextCipher}`; sealed.set(id, value); return id }
const open = async (value: string) => { const clear = sealed.get(value); if (!clear) throw new Error("bad cipher"); return clear }
const prepared = (): PreparedServerProbe => ({ receiptId, serverId: 9, tool: preparedTool, expiresAt: new Date(now + 300000).toISOString(), bindingCiphertext: "inner-private", ...(preparedTool === "bandwidth" ? { destinationServerId: mismatchDestination ? 3 : 2 } : {}) })
const preflight = async (value: PreparedServerProbe, signal?: AbortSignal) => { events.push("preflight"); if (signal?.aborted || expiredPrepared) throw new ServerProbeExecutionError("CANCELLED", "cancelled", 502); await readAuthority(); await assertProtections([value.serverId, ...(value.destinationServerId ? [value.destinationServerId] : [])]); return { receiptId: value.receiptId, bindingProof: "3".repeat(64), tool: value.tool, serverIds: [value.serverId, ...(value.destinationServerId ? [value.destinationServerId] : [])], expiresAt: value.expiresAt } }
const journal: ServerProbeJournal = {
claim: async () => { events.push("claim"); return "claimed" }, intent: async () => { events.push("intent") }, received: async () => {}, quarantine: async () => {},
finish: async value => { receipts.set(value.receiptId, value) },
read: async id => { events.push("journal.read"); return receipts.get(id) },
}
const deps: ServerProbeMcpDependencies = {
retiredSource,
authority: (() => ({ readAuthority, assertProtections })) as ServerProbeMcpDependencies["authority"],
historicalAuthority: ((currentPrincipal: typeof principal) => ({ readAuthority: async () => { events.push("read-authority"); return { ...await readAuthority(), connectionId: currentPrincipal.connectionId, credentialId: currentPrincipal.credentialId } }, assertProtections })) as ServerProbeMcpDependencies["historicalAuthority"],
history: dependencies => createServerProbeJournalHistoryReader({ ...dependencies, loadArchive: async binding => {
events.push("history.read"); const receipt = receipts.get(binding.receiptId); if (!receipt) return []
const identity = binding.context.identity; assert.equal(identity.kind, "mcp"); if (identity.kind !== "mcp") return []
assert.equal(identity.credentialId, credentialId)
const plaintext = JSON.stringify({ version: 1, context: binding.context, bindingProof: binding.bindingProof, receipt })
const ciphertext = "archive-" + await proof(plaintext); sealed.set(ciphertext, plaintext)
return [{ receipt_id: binding.receiptId, binding_proof: binding.bindingProof, identity_kind: "mcp", execution_id: operationId, actor_id: "owner", authentication: "mcp", mcp_connection_id: connectionId, mcp_credential_id: credentialId, request_id: null, authority_issued_at: now - 1000, authority_expires_at: new Date(now + 600000), source_server_id: receipt.serverId, destination_server_id: receipt.destinationServerId ?? null, tool: receipt.tool, state: receipt.state, attempted_stages: receipt.attemptedStages, completed_stages: receipt.completedStages, receipt_ciphertext: ciphertext, receipt_proof: await proof(plaintext), quarantine_count: "0" }]
}, crypto: { decrypt: open, proof: (_domain, value) => proof(value) } }),
executor: ((options: { journal?: ServerProbeJournal }) => ({
prepare: async (_id: number, request: { tool: PreparedServerProbe["tool"] }) => { events.push("prepare"); preparedTool = request.tool; return prepared() }, preflight,
execute: async (value: PreparedServerProbe, signal?: AbortSignal) => {
if (signal) events.push("trusted-signal")
events.push("execute"); if (dispatchFailure) throw dispatchFailure
assert.ok(options.journal); await options.journal.claim({ receiptId, bindingProof: "3".repeat(64), transport: "mcp", ownerId: "owner", connectionId, serverIds: [9, ...(value.destinationServerId ? [value.destinationServerId] : [])], tool: value.tool })
const receipt: ServerProbeReceipt = { receiptId, serverId: value.serverId, ...(value.destinationServerId ? { destinationServerId: value.destinationServerId } : {}), tool: value.tool, state: "completed", httpStatus: 200, output: "PUBLIC_MEASUREMENT".repeat(900), data: { kind: value.tool, reply: true }, attemptedStages: 1, completedStages: 1, automaticReplay: false, forwardingVerified: false }
await options.journal.finish(receipt); return receipt
},
})) as ServerProbeMcpDependencies["executor"],
journal: (ctx, dependencies, execution) => { assert.equal(execution?.deadlineAt, Date.parse(prepared().expiresAt)); contexts.push(ctx); return { ...journal, read: async (...args) => { await dependencies.assertAuthority(ctx, args[1]); return journal.read(...args) }, claim: async binding => { await dependencies.assertAuthority(ctx, binding.bindingProof); return journal.claim(binding) } } },
query: async (sql, values) => {
events.push("sql"); if (revokeOnQuery) revoked = true
if (sql.includes("probe-history:scope")) { assert.equal(values[2], connectionId); assert.equal(values[3], "owner"); return liveHistory ? [{ source_server_id: 9, destination_server_id: preparedTool === "bandwidth" ? 2 : null }] : [] }
if (sql.includes("FROM servers")) { assert.ok(sql.includes("ANY($1::int[])")); assert.deepEqual(values[0], scope); return sql.includes("AND id=$2") ? [] : [{ id: 2, host: "peer", enabled: true }] }
if (!sql.includes("mcp_operation_steps")) return [{ evidence: { sealed: await seal(JSON.stringify(evidence)) } }]
assert.equal(values[0], connectionId); assert.equal(values[1], "owner"); assert.equal(values[2], "servers.probes.run")
const intended = redact(evidence.steps[0])
assert.equal(fingerprint(JSON.parse(String(values[4]))), fingerprint(intended))
if (sqlTamper === "none") return []
const row = { operation_id: operationId, operation_state: operationState, plan_state: operationState === "running" ? "executing" : "completed", approved_by: "approver", step_state: stepState, step_index: 0, intended_state: intended, result, evidence: { sealed: await seal(JSON.stringify(evidence)) } }
if (sqlTamper === "digest") row.intended_state = { mismatch: true }
if (sqlTamper === "unapproved") row.approved_by = ""
if (sqlTamper === "step") row.step_index = 1
if (sqlTamper === "operation") row.operation_id = randomUUID()
if (sqlTamper === "evidence") row.evidence.sealed = await seal(JSON.stringify({ ...evidence, requiresHuman: false }))
return sqlTamper === "duplicate" ? [row, row] : [row]
}, seal, open, proof, now: () => clock,
}
const adapter = createServerProbeMcpAdapter(deps)
const retireHistory = () => { liveHistory = false }
const prepare = async (tool: PreparedServerProbe["tool"] = "ping") => { evidence = await adapter.prepare({ serverId: 9, request: tool === "bandwidth" ? { tool, bwRemoteAddress: "peer", dstServerId: 8 } : { tool, target: "192.0.2.1" } }, context); const step = evidence.steps[0]; assert.equal(step.kind, "service"); return step.kind === "service" ? step.input : assert.fail() }
return { adapter, events, receipts, contexts, prepare, retireHistory, setResult: (value: unknown) => { result = value; stepState = "applied" }, finishOperation: () => { operationState = "applied"; stepState = "acknowledged" }, setTamper: (value?: string) => { sqlTamper = value }, revoke: () => { revoked = true }, setScope: (value: number[]) => { scope = value }, fail: (value: unknown) => { dispatchFailure = value }, revokeOnQuery: () => { revokeOnQuery = true }, evidence: () => evidence, mismatchDestination: () => { mismatchDestination = true }, expirePrepared: () => { expiredPrepared = true; clock = now + 3600000 } }
}
for (const tool of ["ping", "traceroute", "bandwidth", "dns", "route", "mtu"] as const) {
const f = fixture(), input = await f.prepare(tool), evidence = f.evidence()
assert.equal(evidence.requiresHuman, true); assert.equal(evidence.verificationScope, "acknowledgement"); assert.equal(evidence.mutationCost, tool === "mtu" ? 11 : tool === "ping" || tool === "traceroute" ? 2 : 1)
assert.deepEqual(evidence.target, { scope: "servers", serverIds: tool === "bandwidth" ? [2, 9] : [9] }); assert.ok(!f.events.includes("sql") && !f.events.includes("claim"))
if (tool === "bandwidth") assert.ok(evidence.warnings.some(text => text.includes("2 (запрошен 8)")))
const before = await f.adapter.service.current(input); assert.deepEqual(before, evidence.before); assert.equal(f.contexts.length, 0)
const output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 }) as Record<string, unknown>
assert.equal(output.operationId, operationId); assert.equal(output.status, "completed"); assert.ok(!JSON.stringify(output).includes("PUBLIC_MEASUREMENT")); assert.ok(!("confirmedWrites" in output)); assert.ok(Number(output.totalPages) > 1)
assert.equal(f.contexts[0].identity.executionId, operationId); assert.equal(f.contexts[0].identity.issuedAt, now - 1000)
f.setResult(output); assert.equal(await f.adapter.service.verify!(input, output), true)
assert.equal(await f.adapter.service.verify!(input, { ...output, receiptId: randomUUID() }), false)
f.finishOperation(); const executions = f.events.filter(event => event === "execute").length
assert.deepEqual(await f.adapter.service.reconcile!(input, async () => {}), { matchesDesiredState: true, matchesOriginalState: false })
const page = await f.adapter.readPage({ operationId, receiptId, page: 0 }, context)
assert.equal(page.sha256, output.sha256); assert.ok(Buffer.from(page.contentB64, "base64").toString().includes("PUBLIC_MEASUREMENT")); assert.equal(f.events.filter(event => event === "execute").length, executions)
await assert.rejects(f.adapter.readPage({ operationId, receiptId: randomUUID(), page: 0 }, context)); assertions += 18
}
for (const tamper of ["none", "duplicate", "digest", "step", "unapproved", "operation", "evidence"]) {
const f = fixture(), input = await f.prepare(); f.setTamper(tamper)
await assert.rejects(f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 })); assert.equal(f.events.includes("execute"), false); assertions++
}
for (const context of [undefined, { operationId, phase: "recovery" as const }, { operationId, phase: "forward" as const, stepIndex: 1 }]) {
const f = fixture(), input = await f.prepare(); await assert.rejects(f.adapter.service.execute(input, context)); assert.equal(f.events.includes("execute"), false); assertions++
}
{
const f = fixture(), input = await f.prepare()
for (const changed of [{ ...input, operationId }, { ...input, inputDigest: "0".repeat(64) }, { ...input, receiptId: randomUUID() }, { ...input, bindingCiphertext: "foreign" }]) { await assert.rejects(f.adapter.service.current(changed)); assertions++ }
f.revoke(); await assert.rejects(f.adapter.service.current(input)); assert.equal(f.events.includes("journal.read"), false); assertions++
}
{
const f = fixture(); assert.deepEqual(await f.adapter.target({ serverId: 9, request: { tool: "bandwidth", bwRemoteAddress: "peer", dstServerId: 8 } }, context), { scope: "servers", serverIds: [2, 9] }); assertions++
f.setScope([2]); await assert.rejects(f.adapter.target({ serverId: 9, request: { tool: "ping", target: "192.0.2.1" } }, context)); assertions++
}
{
const f = fixture(); f.revokeOnQuery(); await assert.rejects(f.adapter.target({ serverId: 9, request: { tool: "bandwidth", bwRemoteAddress: "peer", dstServerId: 8 } }, context)); assert.equal(f.events.filter(event => event === "sql").length, 1); assertions++
}
{
const f = fixture(), input = await f.prepare(); f.fail(new ServerProbeExecutionError("JOURNAL_UNKNOWN", "PRIVATE_PROVIDER_SECRET", 503, "unknown", 1, 0))
const error = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 }).catch(error => error)
assert.ok(error && typeof error === "object" && "code" in error && "diagnosticProgress" in error)
const progress = error.diagnosticProgress; assert.ok(progress && typeof progress === "object" && "attemptedStages" in progress)
assert.equal(error.code, "JOURNAL_UNKNOWN"); assert.equal(progress.attemptedStages, 1); assert.ok(!JSON.stringify(error).includes("PRIVATE_PROVIDER_SECRET")); assert.ok(!("nativeProgress" in error)); assertions += 3
}
{
const f = fixture(), input = await f.prepare(), output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 }); f.setResult(output)
f.receipts.set(receiptId, { ...f.receipts.get(receiptId)!, serverId: 2 }); assert.equal(await f.adapter.service.verify!(input, output), false); await assert.rejects(f.adapter.readPage({ operationId, receiptId, page: 0 }, context)); assertions += 2
}
for (const input of [{ serverId: 9, request: { tool: "ping", target: "x", credential: "secret" } }, { serverId: 9, operationId, request: { tool: "route", target: "192.0.2.1" } }, { serverId: 9, request: { tool: "route", target: "host" } }]) { assert.equal(serverProbeActionSchema.safeParse(input).success, false); assertions++ }
{
const f = fixture(), input = await f.prepare(), controller = new AbortController()
assert.equal(f.adapter.service.cancellableDiagnostic, true)
await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0, signal: controller.signal }); assert.ok(f.events.includes("trusted-signal"))
controller.abort(); const attempted = f.events.filter(event => event === "execute").length
await assert.rejects(f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0, signal: controller.signal })); assert.equal(f.events.filter(event => event === "execute").length, attempted); assertions += 3
}
assert.ok(!("diagnosticProgress" in serverProbeSafeMcpError(new ServerProbeExecutionError("X", "secret", 500, "unknown", 99), { receiptId, operationId, stageBudget: 1 }))); assertions++
{
const hash = "3".repeat(64), date = (value: number) => new Date(value).toISOString().replace("Z", "000Z")
const retired = { source: { version: 1 as const, operation: "servers.probes.run" as const, operationVersion: 1 as const, tool: "ping" as const,
receiptId, executionId: operationId, connectionId, ownerId: "owner", planId: randomUUID(), originalCredentialId: credentialId,
sourceServerId: 9, serverIds: [9], issuedAt: now - 1000, authorityExpiresAt: date(now + 600000), terminalAt: date(now + 600000),
activityAt: date(now + 600000), gcNotBefore: date(now + 87060000), retentionDays: 1, safetyMs: 60000,
bindingProof: hash, sourceDigest: hash, terminalDigest: hash, stageSnapshotDigest: hash, receiptProof: hash, ciphertextSha256: hash,
}, payloadPurged: false }
let lookups = 0, changeOnRecheck = false
const f = fixture(async lookup => {
lookups++
assert.deepEqual(lookup, { receiptId, operationId, connectionId, ownerId: "owner", scopeServerIds: [2, 9] })
return changeOnRecheck && lookups % 2 === 0 ? undefined : retired
})
const input = await f.prepare(), output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 })
f.setResult(output); f.finishOperation()
const request = { operationId, receiptId, page: 0 }
const live = await f.adapter.readPage(request, context)
assert.equal(lookups, 0)
f.retireHistory()
const rotated = { ...context, principal: { ...principal, credentialId: randomUUID() } }
assert.deepEqual(await f.adapter.readPage(request, rotated), live)
assert.deepEqual(await f.adapter.receiptTarget(request, rotated), { scope: "servers", serverIds: [9] })
const beforeForeign = lookups
await assert.rejects(f.adapter.readPage(request, { ...rotated, connection: { ...context.connection, owner_id: "foreign" } }))
assert.equal(lookups, beforeForeign)
lookups = 0; changeOnRecheck = true
const privateReads = f.events.filter(event => event === "history.read").length
await assert.rejects(f.adapter.readPage(request, rotated))
assert.equal(f.events.filter(event => event === "history.read").length, privateReads)
assertions += 7
}
{
const f = fixture(), input = await f.prepare("bandwidth"), output = await f.adapter.service.execute(input, { operationId, phase: "forward", stepIndex: 0 }); f.setResult(output); f.finishOperation(); f.expirePrepared()
await assert.rejects(f.adapter.service.current(input))
const rotatedContext = { ...context, principal: { ...principal, credentialId: randomUUID(), credentialExpiresAt: new Date(now + 7200000).toISOString() } }
const preflights = f.events.filter(event => event === "preflight").length, claims = f.events.filter(event => event === "claim").length
const page = await f.adapter.readPage({ operationId, receiptId, page: 0 }, rotatedContext)
assert.equal(page.receiptId, receiptId); assert.deepEqual(await f.adapter.receiptTarget({ operationId, receiptId, page: 0 }, rotatedContext), { scope: "servers", serverIds: [2, 9] })
assert.equal(f.events.filter(event => event === "preflight").length, preflights); assert.equal(f.events.filter(event => event === "claim").length, claims)
f.setScope([9]); await assert.rejects(f.adapter.readPage({ operationId, receiptId, page: 0 }, rotatedContext)); f.setScope([2, 9])
await assert.rejects(f.adapter.readPage({ operationId, receiptId, page: 0 }, { ...rotatedContext, principal: { ...rotatedContext.principal, connectionId: randomUUID() } }))
await assert.rejects(f.adapter.readPage({ operationId, receiptId, page: 0 }, { ...rotatedContext, connection: { ...context.connection, owner_id: "foreign" } }))
f.revoke(); await assert.rejects(f.adapter.readPage({ operationId, receiptId, page: 0 }, rotatedContext)); assertions += 8
}
{
const f = fixture(), input = await f.prepare("bandwidth")
assert.deepEqual(await f.adapter.service.diagnosticIdentity!(input, { operationId, phase: "forward", stepIndex: 0 }), { receiptId, stageBudget: 1 })
assert.equal(f.events.includes("execute"), false); assert.equal(f.events.includes("claim"), false)
await assert.rejects(f.adapter.service.diagnosticIdentity!(input, { operationId, phase: "recovery" }))
await assert.rejects(f.adapter.service.diagnosticIdentity!({ ...input, inputDigest: "0".repeat(64) }, { operationId, phase: "forward", stepIndex: 0 }))
f.revoke(); await assert.rejects(f.adapter.service.diagnosticIdentity!(input, { operationId, phase: "forward", stepIndex: 0 })); assertions += 6
}
console.log(`server-probe MCP adapter: ${assertions} inert assertions passed`)