Files
MikrotikManager/backend/src/modules/mcp/ospf-owner-graph.test.ts
T

94 lines
7.3 KiB
TypeScript

import assert from "node:assert/strict"
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
import { fingerprint, McpError, redact } from "./security.js"
import { createRosPrefix } from "./ros-prefix.js"
import { rosSnapshotVersion, type RosSnapshot } from "./resources.js"
const ids = [1, 2, 3]
const graph = { family: "ipv4" as const, sourceSecretProofs: ids.map(serverId => ({ serverId, proof: fingerprint({ serverId }) })), instances: ids.map(serverId => ({ serverId, fingerprint: fingerprint({ serverId, native: "instances" }) })) }
let reads: number[] = [], sourcesCurrent = true, revoked = false, changed = 0
const authorize = async () => { if (revoked) throw new Error("Revoked") }
const dependencies = {
sources: async (selected: number[], expected: typeof graph.sourceSecretProofs) => {
assert.deepEqual(selected, ids); assert.deepEqual(expected, graph.sourceSecretProofs)
return async () => { if (!sourcesCurrent) throw new Error("Changed source") }
},
instance: async (id: number) => { reads.push(id); return changed === id ? fingerprint("changed") : graph.instances.find(row => row.serverId === id)!.fingerprint },
}
await verifyOspfOwnerGraph(graph, ids, authorize, dependencies)
assert.deepEqual(reads, ids)
changed = 3; reads = []
await assert.rejects(verifyOspfOwnerGraph(graph, ids, authorize, dependencies), (error: { code?: string }) => error.code === "STATE_CONFLICT")
assert.deepEqual(reads, ids, "A previously unselected owner remains part of every dispatch check")
changed = 0; reads = []; sourcesCurrent = false
await assert.rejects(verifyOspfOwnerGraph(graph, ids, authorize, dependencies)); assert.deepEqual(reads, [])
sourcesCurrent = true
reads = []
await assert.rejects(verifyOspfOwnerGraph(graph, ids, authorize, { ...dependencies, instance: async id => { const result = await dependencies.instance(id); if (id === 1) sourcesCurrent = false; return result } }))
assert.deepEqual(reads, [1], "Endpoint drift stops before polling another graph candidate")
sourcesCurrent = true
await assert.rejects(verifyOspfOwnerGraph(graph, ids, authorize, { ...dependencies, instance: async id => { const result = await dependencies.instance(id); if (id === 3) sourcesCurrent = false; return result } }))
sourcesCurrent = true; reads = []
await assert.rejects(verifyOspfOwnerGraph(graph, ids, authorize, { ...dependencies, instance: async id => { const result = await dependencies.instance(id); if (id === 1) revoked = true; return result } }))
assert.deepEqual(reads, [1], "Revoked access stops before the next native read")
revoked = false
for (const invalid of [{ ...graph, instances: [...graph.instances, graph.instances[0]] }, { ...graph, sourceSecretProofs: graph.sourceSecretProofs.slice(1) }, { ...graph, instances: [{ serverId: 99, fingerprint: fingerprint("foreign") }] }]) {
reads = []; await assert.rejects(verifyOspfOwnerGraph(invalid, ids, authorize, dependencies)); assert.deepEqual(reads, [])
}
assert.equal((redact({ ospfOwnerGraph: graph }) as { ospfOwnerGraph: { sourceSecretProofs: unknown } }).ospfOwnerGraph.sourceSecretProofs, "[redacted]")
console.log("OSPF owner graph: full negative/positive owner set, endpoint checks before/after native reads, per-read live grant, scoped identities and private proofs")
// The context is reused, but every catalog callback must revalidate its live
// epoch. A->B->A restores endpoint fields, never the original epoch/proof.
let contexts = 0, sourceChecks = 0, epoch = 0, grantRevoked = false
const lightSources = {
sources: async (selected: number[], expected: typeof graph.sourceSecretProofs) => {
contexts++; assert.deepEqual(selected, ids); assert.deepEqual(expected, graph.sourceSecretProofs)
const sealedEpoch = epoch
return async () => { sourceChecks++; if (epoch !== sealedEpoch) throw new McpError("STATE_CONFLICT", "Source epoch changed") }
},
}
const sourceGuard = createOspfOwnerSourceGuard(graph, ids, lightSources)
const catalogAuthorize = async () => {
if (grantRevoked) throw new McpError("PERMISSION_DENIED", "Revoked")
await sourceGuard()
}
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
const catalog: RosSnapshot = { serverId: 1, resource: "bridge", rows: catalogRows, fingerprint: fingerprint(catalogRows), projectionVersion: rosSnapshotVersion("bridge"), sourceProof: "a".repeat(64) }
reads = []
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
// Match readRosSnapshot's normal three grant/source fences without polling a
// device; the prefix adds a fourth fence at the end of its catalog pass.
await beforeRead(); await beforeRead(); await beforeRead(); return structuredClone(catalog)
})
for (let index = 0; index < 32; index++) await catalogPrefix.check(catalogAuthorize)
assert.equal(contexts, 1, "Operation-local source context must be initialized once")
assert.equal(sourceChecks, 128, "Live source checks must never be memoized away")
assert.deepEqual(reads, [], "Catalog checks must not invoke full graph/native polling")
epoch = 1; epoch = 2
await assert.rejects(catalogPrefix.check(catalogAuthorize), error => error instanceof McpError && error.code === "STATE_CONFLICT")
assert.equal(contexts, 1, "ABA cannot rebind a memoized guard to a fresh epoch")
grantRevoked = true
const checksBeforeRevoke = sourceChecks
await assert.rejects(catalogPrefix.check(catalogAuthorize), error => error instanceof McpError && error.code === "PERMISSION_DENIED")
assert.equal(sourceChecks, checksBeforeRevoke)
for (const invalid of [{ ...graph, instances: [...graph.instances, graph.instances[0]] }, { ...graph, sourceSecretProofs: graph.sourceSecretProofs.slice(1) }, { ...graph, instances: [{ serverId: 99, fingerprint: fingerprint("foreign") }] }]) {
assert.throws(() => createOspfOwnerSourceGuard(invalid, ids, lightSources))
}
assert.equal(contexts, 1, "Foreign/invalid graphs must fail before even initializing source access")
// Only full graph conflicts after an acknowledged write become verification
// failure. Admission conflicts, grant failures and I/O errors keep their codes.
changed = 2; reads = []; sourcesCurrent = true; revoked = false
await assert.rejects(verifyOspfOwnerGraphAtPhase(graph, ids, authorize, "admission", dependencies), error => error instanceof McpError && error.code === "STATE_CONFLICT")
await assert.rejects(verifyOspfOwnerGraphAtPhase(graph, ids, authorize, "post-ack", dependencies), error => error instanceof McpError && error.code === "VERIFICATION_FAILED")
changed = 0
const permission = new McpError("PERMISSION_DENIED", "Paused")
await assert.rejects(verifyOspfOwnerGraphAtPhase(graph, ids, async () => { throw permission }, "post-ack", dependencies), error => error === permission)
const io = new Error("Native unavailable")
await assert.rejects(verifyOspfOwnerGraphAtPhase(graph, ids, authorize, "post-ack", { ...dependencies, instance: async () => { throw io } }), error => error === io)
reads = []
for (let boundary = 0; boundary < 5; boundary++) await verifyOspfOwnerGraphAtPhase(graph, ids, authorize, boundary < 3 ? "admission" : "post-ack", dependencies)
assert.equal(reads.length, 15, "Five explicit full-graph boundaries poll three owners each")
console.log("OSPF lightweight catalog guards: one bound source context, 128 live checks with zero graph polls, ABA/revocation/scope rejection, five full boundaries and post-ACK conflict classification")