import { spawn } from "node:child_process"; import { test } from "node:test"; import assert from "node:assert/strict"; import { fatalBrokerReason, PinMismatchError, topicMatches } from "../src/broker-nats.ts"; // novox/hq issue 058 (and its review): serve mode retries a broker that is not up yet, but must // give up at once on a failure waiting cannot fix — otherwise a permanent fault loops for ever // disguised as "not reachable". This is the classifier that draws the line; the bed cannot test it // (it starts the consumer only after the broker is up), so it is proven here. test("a broker that is not up yet is retryable, not fatal", () => { for (const err of [ Object.assign(new Error("connect ECONNREFUSED 10.42.0.1:5671"), { code: "ECONNREFUSED" }), Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" }), Object.assign(new Error("getaddrinfo EAI_AGAIN anchor.internal"), { code: "EAI_AGAIN" }), new Error("timed out fetching the broker's certificate"), ]) { assert.equal(fatalBrokerReason(err), null, `should retry: ${(err as Error).message}`); } }); test("a certificate that does not match the pin is fatal, by type not by message", () => { // Typed, so rewording the message cannot turn an impostor back into an infinite retry. assert.notEqual(fatalBrokerReason(new PinMismatchError("anything at all")), null); // A plain Error with pin-ish words is NOT treated as the pin case — only the type is. assert.equal(fatalBrokerReason(new Error("the pinned value was fine")), null); }); test("a malformed broker URL is fatal — it never parses on the next try", () => { assert.notEqual(fatalBrokerReason(Object.assign(new Error("Invalid URL"), { code: "ERR_INVALID_URL" })), null); assert.notEqual(fatalBrokerReason(new Error("Invalid URL: not-a-url")), null); }); test("a refused login is fatal — a wrong or revoked credential, not an absent broker", () => { // The bus refuses a login in its own words; each is final, because the next try says the same. for (const msg of ["Authorization Violation", "nats: user authentication expired", "Permissions Violation for Subscription to \"x\""]) { assert.notEqual(fatalBrokerReason(new Error(msg)), null, `should be fatal: ${msg}`); } }); test("a non-Error value does not crash the classifier", () => { assert.equal(fatalBrokerReason("just a string"), null); assert.equal(fatalBrokerReason(undefined), null); }); // **A module asked to prepare its state and naming nothing is a failure, not a no-op** (novox/hq // ADR 0135). The mesh asks this only of a module whose manifest says it prepares something, so an // image that names nothing was built wrong, and exiting 0 would let that version serve against a // state nobody shaped. test("preparing with nothing named fails rather than passing quietly", async () => { const runtime = new URL("../dist/main.js", import.meta.url).pathname; const ran = await new Promise<{ code: number | null; said: string }>((resolve) => { const child = spawn(process.execPath, [runtime, "prepare"], { env: { ...process.env, MESH_PREPARE: "" }, }); let said = ""; child.stderr.on("data", (chunk) => (said += String(chunk))); child.on("close", (code) => resolve({ code, said })); }); assert.notEqual(ran.code, 0, "a module that prepares nothing exited 0, so its version would serve"); assert.match(ran.said, /MESH_PREPARE/); }); // **One durable consumer feeds one reader, however many patterns a module registers.** // // A module has exactly one consumer, so two readers of it would each take half the messages — and a // reader that received one its own pattern does not match acknowledges it, which is right for a // filter wider than anything registered and silent loss when it is another handler's. The matching is // therefore pure and tested as such: what a message is for is decided by the patterns registered, not // by which reader happened to fetch it. test("a message is for every pattern that matches it, and nothing else", () => { const registered = ["mesh-build-machine.built", "mesh-controller.built-before"]; const matched = (key: string) => registered.filter((p) => topicMatches(p, key)); assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]); assert.deepEqual(matched("mesh-controller.built-before"), ["mesh-controller.built-before"]); // Nothing registered for it: the consumer's filter is the controller's and may be wider. assert.deepEqual(matched("mesh-catalog.upgraded"), []); // And a handler that asked for everything gets both, which is what the audit logger does. assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-controller.built-before")), ["#"]); });