100 lines
5.1 KiB
TypeScript
100 lines
5.1 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { EventEmitter } from "node:events"
|
|
import http from "node:http"
|
|
import { test, type TestContext } from "node:test"
|
|
import { isRouterOsPreHeaderGetReset, MikrotikClient } from "./mikrotik.js"
|
|
import { createWireguardDependencyReader } from "./wireguard-interface-delete.js"
|
|
|
|
type Outcome = "reset" | "late-reset" | "informational-reset" | "body-reset" | "ok" | "refused" | "invalid-json"
|
|
const reset = () => Object.assign(new Error("fixture reset"), { code: "ECONNRESET" })
|
|
|
|
function fixture(t: TestContext, outcomes: Outcome[]) {
|
|
const paths: string[] = [], timeouts: number[] = []
|
|
t.mock.method(http, "request", (options: http.RequestOptions, receive: (res: unknown) => void) => {
|
|
assert.equal(options.method, "GET")
|
|
paths.push(options.path!)
|
|
const request = new EventEmitter()
|
|
const outcome = outcomes.shift()
|
|
assert(outcome)
|
|
return Object.assign(request, {
|
|
end() {
|
|
if (outcome === "reset") return request.emit("error", reset())
|
|
if (outcome === "informational-reset") {
|
|
request.emit("information", { statusCode: 103 })
|
|
return request.emit("error", reset())
|
|
}
|
|
const response = Object.assign(new EventEmitter(), { headers: {}, statusCode: outcome === "refused" ? 429 : 200 })
|
|
receive(response)
|
|
if (outcome === "late-reset") return request.emit("error", reset())
|
|
if (outcome === "body-reset") return response.emit("error", reset())
|
|
response.emit("data", Buffer.from(outcome === "invalid-json" ? "!" : "[]"))
|
|
response.emit("end")
|
|
},
|
|
})
|
|
})
|
|
const client = new MikrotikClient({ host: "fixture.invalid", port: 80, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
return {
|
|
paths, timeouts,
|
|
client: { get<T>(path: string, timeout?: number, signal?: AbortSignal, maxBytes?: number) {
|
|
assert.equal(maxBytes, 1_048_576)
|
|
timeouts.push(timeout!)
|
|
return client.get<T>(path, timeout, signal, maxBytes)
|
|
} },
|
|
}
|
|
}
|
|
|
|
test("two distinct dependency resets can retry with fresh authority and the original read deadlines", async t => {
|
|
const { client, paths, timeouts } = fixture(t, ["reset", "ok", "reset", "ok", "reset"])
|
|
let authorized = 0, clock = 0
|
|
const read = createWireguardDependencyReader(client, async () => { if (++authorized === 2) clock += 120 }, () => clock)
|
|
assert.deepEqual(await read("/interface"), [])
|
|
assert.deepEqual(await read("/routing/ospf/neighbor"), [])
|
|
await assert.rejects(read("/ip/route"), { code: "STATE_UNAVAILABLE" })
|
|
assert.equal(authorized, 5)
|
|
assert.deepEqual(paths, ["/rest/interface", "/rest/interface", "/rest/routing/ospf/neighbor", "/rest/routing/ospf/neighbor", "/rest/ip/route"])
|
|
assert.deepEqual(timeouts, [10_000, 9_880, 10_000, 10_000, 10_000])
|
|
})
|
|
|
|
test("the same catalogue cannot use the second retry slot", async t => {
|
|
const { client, paths } = fixture(t, ["reset", "reset", "reset"])
|
|
const read = createWireguardDependencyReader(client, async () => {})
|
|
await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
assert.equal(paths.length, 3)
|
|
})
|
|
|
|
test("post-header reset, refusal, malformed body and unproven reset cannot replay", async t => {
|
|
const { client, paths } = fixture(t, ["late-reset", "informational-reset", "body-reset", "refused", "invalid-json"])
|
|
const read = createWireguardDependencyReader(client, async () => {})
|
|
for (let index = 0; index < 5; index++) await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
assert.equal(paths.length, 5)
|
|
const synthetic = reset()
|
|
assert.equal(isRouterOsPreHeaderGetReset(synthetic), false)
|
|
let calls = 0
|
|
const unknown = createWireguardDependencyReader({ async get() { calls++; throw synthetic } }, async () => {})
|
|
await assert.rejects(unknown("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
assert.equal(calls, 1)
|
|
})
|
|
|
|
test("IPv4 and IPv6 reads share the dependency retry budget", async t => {
|
|
const { client, paths } = fixture(t, ["reset", "ok", "reset", "ok", "reset"])
|
|
const read = createWireguardDependencyReader(client, async () => {})
|
|
assert.deepEqual(await read("/ip/address"), [])
|
|
assert.deepEqual(await read("/ipv6/address"), [])
|
|
await assert.rejects(read("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
assert.deepEqual(paths, ["/rest/ip/address", "/rest/ip/address", "/rest/ipv6/address", "/rest/ipv6/address", "/rest/interface"])
|
|
})
|
|
|
|
test("retry authority denial and deadline expiry stop before transport", async t => {
|
|
const { client, paths } = fixture(t, ["reset", "reset"])
|
|
const denied = new Error("authority denied")
|
|
let authorized = 0
|
|
const read = createWireguardDependencyReader(client, async () => { if (++authorized === 2) throw denied })
|
|
await assert.rejects(read("/interface"), error => error === denied)
|
|
let clock = 0
|
|
const expired = createWireguardDependencyReader(client, async () => { if (++authorized === 4) clock = 10_000 }, () => clock)
|
|
await assert.rejects(expired("/interface"), { code: "STATE_UNAVAILABLE" })
|
|
assert.equal(authorized, 4)
|
|
assert.equal(paths.length, 2)
|
|
})
|