Files
MikrotikManager/backend/src/test/traffic-flow-chr-disabled-runner.test.ts
T

117 lines
6.1 KiB
TypeScript

import assert from "node:assert/strict"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { test } from "node:test"
import { chrExportDrainMs, executeDisabledChrCapture, type DisabledChrCaptureOptions } from "./traffic-flow-chr-disabled-runner.js"
test("export drain requires a bounded inactive-flow timeout plus two seconds", () => {
assert.equal(chrExportDrainMs("15s"), 17000)
assert.equal(chrExportDrainMs("00:00:30"), 32000)
for (const value of ["", "0s", "1m1s", "300", undefined]) {
assert.throws(() => chrExportDrainMs(value), /Inactive flow timeout/)
}
})
const cases = {
"create ACK loss": /Target creation outcome unknown/,
"unsealed direct capture": /capture producer stopped/,
"staged capture ACK missing": /capture acknowledgement unavailable/,
"staged enable ACK loss": /Target enable outcome unknown/,
"unsealed staged capture": /capture producer stopped/,
} as const
for (const mode of Object.keys(cases) as (keyof typeof cases)[]) test(mode, async () => {
const staged = mode.includes("staged")
const dir = await mkdtemp(join(tmpdir(), "mm-chr-disabled-"))
const actions: string[] = []
const cleanup: boolean[] = []
let topologyChecks = 0, topologyChecksAtCapture = 0
function router(index: number) {
const name = "nfa012bcde" + (index === 0 ? "ea" : "eb")
const baseline = { deviceId: String(index), flow: { ".id": "*F", enabled: "false", interfaces: "all", "inactive-flow-timeout": "15s" },
ipfix: { ".id": "*A", ...Object.fromEntries(["bytes", "packets", "src-address", "dst-address", "protocol", "src-port",
"dst-port", "in-interface", "out-interface"].map(key => [key, "true"])) }, targets: [] as Record<string, unknown>[] }
const state = structuredClone(baseline)
return { baseline, transitInterface: name, interfaces: [{ id: "*1", name, ifIndex: "1" },
{ id: "*2", name: "nfa012bcde" + (index === 0 ? "ma" : "mb"), ifIndex: "2" }] as const,
read: async () => state, counters: async () => {
if (staged) actions.push("counters:" + index)
return { startedAt: 1, observedAt: 1, rows: [] }
},
native: { async post() { throw Error("unexpected POST") },
async patch<T>(_path: string, body: Record<string, string>, _timeout?: number, fence?: () => Promise<unknown>) {
await fence?.()
actions.push("flow:" + index)
Object.assign(state.flow, body, { enabled: "true" })
return undefined as T
} },
targetNative: { async put<T>(_path: string, body: Record<string, string>, _timeout?: number, fence?: () => Promise<unknown>): Promise<T> {
await fence?.()
actions.push("target:" + index)
if (mode === "create ACK loss") throw Error("lost ACK")
assert.equal(body.disabled, staged ? "yes" : undefined)
state.targets.push({ ...body, ".id": "*C", disabled: body.disabled ?? "false" })
return { ".id": "*C" } as T
}, async patch<T>(path: string, body: Record<string, string>, _timeout?: number, fence?: () => Promise<unknown>) {
await fence?.()
assert.equal(path, "/ip/traffic-flow/target/*C")
assert.deepEqual(body, { disabled: "no" })
assert.equal(state.targets[0]?.disabled, "yes")
state.targets[0].disabled = "false"
actions.push("enable:" + index)
if (mode === "staged enable ACK loss") throw Error("lost ACK")
return { ".id": "*C" } as T
}, async delete() { actions.push("unexpected inverse") } } }
}
const journalPath = join(dir, "owned.jsonl")
try {
const options: DisabledChrCaptureOptions = {
journalPath, deadlineAt: Date.now() + 180000, ...(staged ? { stagedTarget: true as const } : {}),
scope: { sources: ["192.0.2.1", "192.0.2.2"], destination: "192.0.2.3", port: 50000 },
expected: [], serverIds: [1, 2], routers: [router(0), router(1)],
assertTopology: async () => { topologyChecks++ }, authorize: async () => {},
capture: async exercise => {
if (staged) actions.push("capture")
topologyChecksAtCapture = topologyChecks
await exercise(new AbortController().signal, staged && mode !== "staged capture ACK missing"
? { kind: "staged-ipfix-timeout-20s", captureDeadlineAt: Date.now() + 120000 } : undefined)
throw Error("unexpected capture")
},
generate: async () => {
if (staged) {
assert.equal(topologyChecks - topologyChecksAtCapture, 5) // Two per enable, one before generation.
actions.push("generate")
}
throw Error("capture producer stopped before seal")
},
drainCollector: async () => { actions.push("unexpected drain") },
reconcile: async () => {}, reportRouterCleanup: clean => cleanup.push(clean),
}
await assert.rejects(executeDisabledChrCapture(options), cases[mode])
await assert.rejects(executeDisabledChrCapture(options), { code: "EEXIST" })
const prefix = ["flow:0", "target:0", "flow:1", "target:1"]
assert.deepEqual(actions, {
"create ACK loss": prefix.slice(0, 2),
"unsealed direct capture": prefix,
"staged capture ACK missing": [...prefix, "capture"],
"staged enable ACK loss": [...prefix, "capture", "enable:0"],
"unsealed staged capture": [...prefix, "capture", "enable:0", "enable:1", "counters:0", "counters:1", "generate"],
}[mode])
assert.deepEqual(cleanup, [false])
const journal = (await readFile(journalPath, "utf8")).trim().split("\n").map(line => JSON.parse(line))
if (staged && mode !== "staged capture ACK missing") {
assert(journal.find(entry => entry.phase === "capture-ready"))
}
assert.deepEqual(journal.at(-1), {
"create ACK loss": { device: 0, action: "create", phase: "intent" },
"unsealed direct capture": { device: 1, action: "create", phase: "verified", id: "*C" },
"staged capture ACK missing": { phase: "targets-staged-disabled" },
"staged enable ACK loss": { device: 0, action: "enable", phase: "intent", id: "*C" },
"unsealed staged capture": { device: 1, action: "enable", phase: "verified", id: "*C" },
}[mode])
} finally {
await rm(dir, { recursive: true, force: true })
}
})