176 lines
16 KiB
TypeScript
176 lines
16 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { createHmac, randomUUID } from "node:crypto"
|
|
import { createServer } from "node:http"
|
|
import { test } from "node:test"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import { MikrotikClient } from "./mikrotik.js"
|
|
import { closePool, pool } from "../db/index.js"
|
|
import { createBandwidthAdmission, consumeBandwidthNativePermit, type BandwidthAdmissionDependencies, type BandwidthAdmissionInput, type BandwidthJournalAck, type BandwidthNativePermit } from "./bandwidth-admission.js"
|
|
|
|
const canonical = (value: unknown): string => Array.isArray(value) ? "[" + value.map(canonical).join(",") + "]" : value && typeof value === "object" ? "{" + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => JSON.stringify(key) + ":" + canonical(item)).join(",") + "}" : JSON.stringify(value)
|
|
const deferred = () => { let resolve!: () => void; const promise = new Promise<void>(done => { resolve = done }); return { promise, resolve } }
|
|
const body = () => ({ address: "192.0.2.2", user: "synthetic-destination", password: "synthetic-secret", protocol: "tcp", direction: "both", duration: "2147483647s" })
|
|
|
|
test("staged strict native permit: private admission and actual localhost HTTP", async t => {
|
|
const received: Array<{ path: string; body: unknown; auth: string }> = []
|
|
const server = createServer(async (request, response) => {
|
|
let text = ""; for await (const chunk of request) text += String(chunk)
|
|
received.push({ path: request.url!, body: JSON.parse(text), auth: request.headers.authorization! })
|
|
response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify([{ status: "done testing" }]))
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address(); assert(address && typeof address !== "string")
|
|
const params = () => ({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: true, username: "synthetic-source", password: "source-secret" })
|
|
const strict = () => new MikrotikClient(params(), undefined, { requireBandwidthPermit: true })
|
|
const originalQuery = pool.query, originalConnect = pool.connect
|
|
let sqlCalls = 0, groups = 0
|
|
pool.query = (() => { sqlCalls++; throw Error("Native permit fixture must perform no SQL") }) as never
|
|
pool.connect = (() => { sqlCalls++; throw Error("Native permit fixture must acquire no backend") }) as never
|
|
function fixture(client: object | undefined, send: BandwidthAdmissionDependencies["send"], durationMs = 2000) {
|
|
const input: BandwidthAdmissionInput = { attemptId: randomUUID(), callerKind: "rest-speed", operationId: "owned-localhost-fixture", request: { duration: 2147483647 },
|
|
source: { canonicalNodeId: "synthetic-A", endpointProof: "a".repeat(64) }, destination: { canonicalNodeId: "synthetic-B", endpointProof: "b".repeat(64) },
|
|
authorityProof: "c".repeat(64), authorityGeneration: "synthetic-actor-v1", controlGeneration: "synthetic-control-v1", expiresAt: Date.now() + durationMs, durationSec: 2147483647, nativeBody: body() }
|
|
const transitions: string[] = []
|
|
let occupancy = "free"
|
|
const ack = (transition: BandwidthJournalAck["transition"], binding: Parameters<BandwidthAdmissionDependencies["authorize"]>[0]): BandwidthJournalAck => {
|
|
transitions.push(transition); return { outcome: "new", transition, ackId: randomUUID(), attemptId: binding.attemptId, bindingProof: binding.bindingProof, controlGeneration: binding.controlGeneration }
|
|
}
|
|
const deps: BandwidthAdmissionDependencies = { nativeClient: client, now: Date.now, cleanupBudgetMs: 100,
|
|
keyedProof: async (purpose, value) => createHmac("sha256", "synthetic-unit-journal-key").update(purpose + ":" + canonical(value)).digest("hex"),
|
|
authorize: async binding => ({ authorityProof: binding.authorityProof, authorityGeneration: binding.authorityGeneration, controlGeneration: binding.controlGeneration, expiresAt: binding.expiresAt }),
|
|
endpoints: async binding => ({ source: binding.source, destination: binding.destination }), send,
|
|
journal: {
|
|
reserve: async binding => { assert.equal(occupancy, "free"); occupancy = "reserved"; return ack("reserve", binding) },
|
|
arm: async binding => { assert.equal(occupancy, "reserved"); occupancy = "armed"; return ack("arm", binding) },
|
|
complete: async binding => { assert.equal(occupancy, "armed"); occupancy = "free"; return ack("complete", binding) },
|
|
releaseBeforeDispatch: async binding => { occupancy = "free"; return ack("release-before-dispatch", binding) },
|
|
quarantine: async binding => { occupancy = "unknown-busy"; return ack("quarantine", binding) },
|
|
} }
|
|
return { deps, input, transitions, occupancy: () => occupancy, gate: () => createBandwidthAdmission(input, deps) }
|
|
}
|
|
const run = async (f: ReturnType<typeof fixture>, signal?: AbortSignal) => { const gate = f.gate(); return gate.dispatch(await gate.reserveAndArm({ deadlineAt: f.input.expiresAt, signal })) }
|
|
const check = async (name: string, work: () => Promise<void>) => { await t.test(name, work); groups++ }
|
|
try {
|
|
await check("unadmitted helper/direct, forged and serialized permits produce zero HTTP", async () => {
|
|
const client = strict(), start = received.length
|
|
await assert.rejects(client.bandwidthTest({ ...body(), durationSec: 2147483647, protocol: "tcp", direction: "both" }))
|
|
for (const permit of [undefined, {}, Object.freeze(Object.create(null)), JSON.parse("{}")]) await assert.rejects(client.post("/tool/bandwidth-test", body(), 500, undefined, undefined, permit as BandwidthNativePermit))
|
|
assert.equal(received.length, start)
|
|
})
|
|
for (const mode of ["helper", "direct"] as const) await check("actual admitted " + mode + " preserves full duration and consumes one permit", async () => {
|
|
const client = strict(); let saved: BandwidthNativePermit | undefined
|
|
const f = fixture(client, async (_binding, native, scope, permit) => {
|
|
assert.equal(f.occupancy(), "armed"); assert.deepEqual(f.transitions, ["reserve", "arm"]); saved = permit
|
|
return mode === "direct" ? client.post("/tool/bandwidth-test", { ...native }, 500, scope.signal, undefined, permit)
|
|
: client.bandwidthTest({ address: native.address, user: native.user, password: native.password, protocol: "tcp", direction: "both", durationSec: 2147483647 }, scope.signal, undefined, permit)
|
|
})
|
|
const start = received.length; assert.equal((await run(f)).remoteCompletionConfirmed, true)
|
|
assert.equal(received.length, start + 1); assert.deepEqual(received.at(-1)!.body, body()); assert.equal(received.at(-1)!.path, "/rest/tool/bandwidth-test")
|
|
assert.equal(f.occupancy(), "free"); await assert.rejects(client.post("/tool/bandwidth-test", body(), 500, undefined, undefined, saved)); assert.equal(received.length, start + 1)
|
|
})
|
|
for (const attack of ["copy", "serialize", "client", "body", "signal", "missing-client"] as const) await check("admitted permit cannot transfer via " + attack, async () => {
|
|
const client = strict(), other = strict(), controller = new AbortController(), start = received.length
|
|
const f = fixture(attack === "missing-client" ? undefined : client, async (_binding, native, scope, permit) => {
|
|
const candidate = attack === "copy" ? { ...permit } : attack === "serialize" ? JSON.parse(JSON.stringify(permit)) : permit
|
|
await assert.rejects((attack === "client" ? other : client).post("/tool/bandwidth-test", attack === "body" ? { ...native, address: "192.0.2.99" } : { ...native }, 500, attack === "signal" ? controller.signal : scope.signal, undefined, candidate))
|
|
if (!["copy", "serialize"].includes(attack)) await assert.rejects(client.post("/tool/bandwidth-test", { ...native }, 500, scope.signal, undefined, permit), "failed genuine use consumes permit")
|
|
throw Error("private send refused")
|
|
})
|
|
await assert.rejects(run(f)); assert.equal(received.length, start); assert.equal(f.occupancy(), "unknown-busy")
|
|
})
|
|
await check("native target captured exactly once despite later dependencies mutation", async () => {
|
|
const client = strict(), other = strict(), start = received.length
|
|
const f = fixture(client, (_binding, native, scope, permit) => client.post("/tool/bandwidth-test", { ...native }, 500, scope.signal, undefined, permit))
|
|
let reads = 0; Object.defineProperty(f.deps, "nativeClient", { configurable: true, get() { reads++; return client } })
|
|
const gate = f.gate(); assert.equal(reads, 1); Object.defineProperty(f.deps, "nativeClient", { value: other })
|
|
await gate.dispatch(await gate.reserveAndArm({ deadlineAt: f.input.expiresAt })); assert.equal(reads, 1); assert.equal(received.length, start + 1)
|
|
})
|
|
await check("strict constructor snapshots mutable endpoint and credentials", async () => {
|
|
const mutable = params(), client = new MikrotikClient(mutable, undefined, { requireBandwidthPermit: true }), start = received.length
|
|
mutable.host = "invalid.invalid"; mutable.port = 1; mutable.username = "MUTATED"; mutable.password = "MUTATED"; mutable.useSsl = true
|
|
const f = fixture(client, (_binding, native, scope, permit) => client.post("/tool/bandwidth-test", { ...native }, 500, scope.signal, undefined, permit))
|
|
await run(f); assert.equal(received.length, start + 1); assert.equal(received.at(-1)!.auth, "Basic " + Buffer.from("synthetic-source:source-secret").toString("base64"))
|
|
})
|
|
await check("strict bodies refuse accessors without evaluation; synchronous body snapshot survives callback mutation", async () => {
|
|
const client = strict(), start = received.length; let reads = 0
|
|
const denied = fixture(client, async (_binding, native, scope, permit) => {
|
|
const accessor = { ...native }; Object.defineProperty(accessor, "address", { enumerable: true, get() { reads++; return native.address } })
|
|
return client.post("/tool/bandwidth-test", accessor, 500, scope.signal, undefined, permit)
|
|
})
|
|
await assert.rejects(run(denied)); assert.equal(reads, 0); assert.equal(received.length, start)
|
|
const accepted = fixture(client, (_binding, native, scope, permit) => {
|
|
const mutable = { ...native }; return client.post("/tool/bandwidth-test", mutable, 500, scope.signal, async () => { mutable.address = "192.0.2.99"; mutable.password = "CHANGED" }, permit)
|
|
})
|
|
await run(accepted); assert.equal(received.length, start + 1); assert.deepEqual(received.at(-1)!.body, body())
|
|
})
|
|
await check("encoded/slashed/case/dot aliases never bypass strict canonical path", async () => {
|
|
const client = strict(), start = received.length
|
|
for (const path of ["/tool/%62andwidth-test", "/tool/%2562andwidth-test", "/%74ool/bandwidth-test", "/tool//bandwidth-test", "/TOOL/BANDWIDTH-TEST", "/x/../tool/bandwidth-test", "/tool/bandwidth-test/", "/tool/bandwidth-test?x=1", "/tool/bandwidth-test?bad=%", "/%74ool/bandwidth-test?bad=%", "/tool/bandwidth-test/%"]) {
|
|
await assert.rejects(client.post(path, body(), 500))
|
|
const f = fixture(client, (_binding, native, scope, permit) => client.post(path, { ...native }, 500, scope.signal, undefined, permit))
|
|
await assert.rejects(run(f))
|
|
}
|
|
assert.equal(received.length, start)
|
|
})
|
|
await check("custom apiPath cannot hide a bandwidth command in the final request target",async()=>{
|
|
const start=received.length
|
|
for(const [apiPath,path] of [["/rest/tool","/bandwidth-test"],["/rest/tool/bandwidth-test",""],["/rest/%74ool","/%62andwidth-test"],["/proxy/rest/tool","/bandwidth-test?bad=%"],["/proxy%25/rest/%74ool","/bandwidth-test"],["/proxy%/rest/%74ool","/bandwidth-test"]]){
|
|
const client=new MikrotikClient({...params(),apiPath},undefined,{requireBandwidthPermit:true})
|
|
await assert.rejects(client.post(path,body(),500))
|
|
const f=fixture(client,(_binding,native,scope,permit)=>client.post(path,{...native},500,scope.signal,undefined,permit))
|
|
await assert.rejects(run(f))
|
|
}
|
|
assert.equal(received.length,start)
|
|
const configured={...params(),apiPath:"/private-proxy/rest"}
|
|
const client=new MikrotikClient(configured,undefined,{requireBandwidthPermit:true})
|
|
configured.apiPath="/rest/tool"
|
|
const valid=fixture(client,(_binding,native,scope,permit)=>client.post("/tool/bandwidth-test",{...native},500,scope.signal,undefined,permit))
|
|
await run(valid)
|
|
assert.equal(received.length,start+1)
|
|
assert.equal(received.at(-1)!.path,"/private-proxy/rest/tool/bandwidth-test")
|
|
})
|
|
await check("clock rollback before mint cannot renew the original native monotonic budget",async()=>{
|
|
const client=strict(),start=received.length,realNow=Date.now
|
|
const f=fixture(client,(_binding,native,scope,permit)=>client.post("/tool/bandwidth-test",{...native},500,scope.signal,undefined,permit),80)
|
|
f.deps.now=()=>Date.now()
|
|
const arm=f.deps.journal.arm.bind(f.deps.journal)
|
|
f.deps.journal.arm=async(binding,scope)=>{
|
|
const ack=await arm(binding,scope)
|
|
Date.now=()=>realNow()-60000
|
|
await delay(120)
|
|
return ack
|
|
}
|
|
try{await assert.rejects(run(f));assert.equal(received.length,start);assert.equal(f.occupancy(),"unknown-busy")}
|
|
finally{Date.now=realNow}
|
|
})
|
|
for (const reason of ["expiry", "abort"] as const) await check("late callback after " + reason + " cannot dispatch saved permit", async () => {
|
|
const client = strict(), controller = new AbortController(), entered = deferred(), release = deferred(), start = received.length
|
|
let late: Promise<unknown> | undefined
|
|
const f = fixture(client, (_binding, native, scope, permit) => {
|
|
late = client.post("/tool/bandwidth-test", { ...native }, 500, scope.signal, async () => { entered.resolve(); await release.promise }, permit)
|
|
return late
|
|
}, reason === "expiry" ? 80 : 2000)
|
|
const pending = run(f, controller.signal), rejected = assert.rejects(pending)
|
|
await entered.promise; if (reason === "abort") controller.abort()
|
|
await rejected; assert.equal(received.length, start); assert.equal(f.occupancy(), "unknown-busy")
|
|
release.resolve(); await assert.rejects(late!); await delay(10); assert.equal(received.length, start)
|
|
})
|
|
await check("verification export cannot mint; fromServer opts into strict mode", async () => {
|
|
assert.throws(() => consumeBandwidthNativePermit({}, strict(), "/tool/bandwidth-test", body()))
|
|
const from = MikrotikClient.fromServer({ ...params(), id: 123 } as Parameters<typeof MikrotikClient.fromServer>[0], { requireBandwidthPermit: true })
|
|
const start = received.length, accessor = body(); let reads = 0
|
|
Object.defineProperty(accessor, "address", { enumerable: true, get() { reads++; return "192.0.2.2" } })
|
|
// Strict-body validation occurs before the server-scoped configuration
|
|
// path. A legacy client would reach the forbidden SQL hook instead.
|
|
await assert.rejects(from.post("/tool/bandwidth-test", accessor, 500)); assert.equal(reads, 0); assert.equal(sqlCalls, 0)
|
|
const aborted = new AbortController(); aborted.abort()
|
|
await assert.rejects(from.bandwidthTest({ ...body(), protocol: "tcp", direction: "both" }, aborted.signal)); assert.equal(received.length, start)
|
|
assert.equal(sqlCalls, 0)
|
|
})
|
|
console.log("Bandwidth native permit:", JSON.stringify({ groups, actualLocalhostHttp: received.length, sqlCalls, realChrCalls: 0, fakeJournalOnly: true, mandatoryAllCallerGate: false }))
|
|
} finally {
|
|
pool.query = originalQuery; pool.connect = originalConnect
|
|
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await closePool()
|
|
}
|
|
})
|