94 lines
7.3 KiB
TypeScript
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")
|