1
19import { describe, expect, it } from "vitest";
20import {
21 ageMsSince,
22 fmtAge,
23 isAuthError,
24 recordUnavailable,
25 verdictFor,
26 type CheckResult
27} from "../../scripts/backup-health-decisions.js";
28
29const NOW = new Date("2026-10-12T12:00:00.000Z");
30const HOUR = 3_600_000;
31
32const r = (status: CheckResult["status"], label = "X", ageMs: number | null = null): CheckResult => ({
33 label,
34 status,
35 detail: `${label} (${status})`,
36 ageMs
37});
38
39describe("ageMsSince / fmtAge", () => {
40 it("measures age from an ISO timestamp", () => {
41 expect(ageMsSince("2026-10-12T11:00:00.000Z", NOW)).toBe(1 * HOUR);
42 expect(ageMsSince("2026-10-12T12:00:00.000Z", NOW)).toBe(0);
43 });
44 it("formats minutes under an hour, one-decimal hours above", () => {
45 expect(fmtAge(5 * 60_000)).toBe("5m");
46 expect(fmtAge(59 * 60_000)).toBe("59m");
47 expect(fmtAge(60 * 60_000)).toBe("1.0h");
48 expect(fmtAge(25.5 * HOUR)).toBe("25.5h");
49 });
50});
51
52describe("isAuthError — a mis-credentialed identity is ALWAYS a failure", () => {
53 it("matches the S3/OS auth failure strings", () => {
54 expect(isAuthError("HTTP 403 Forbidden")).toBe(true);
55 expect(isAuthError("SignatureDoesNotMatch")).toBe(true);
56 expect(isAuthError("AccessDenied")).toBe(true);
57 expect(isAuthError("InvalidAccessKeyId")).toBe(true);
58 expect(isAuthError("InvalidClientTokenId")).toBe(true);
59 expect(isAuthError("An error occurred (Credentials) when calling...")).toBe(true);
60 expect(isAuthError("401 unauthorized")).toBe(true);
61 });
62 it("does NOT match non-auth unreachability", () => {
63 expect(isAuthError("fetch failed: ECONNREFUSED 127.0.0.1:9000")).toBe(false);
64 expect(isAuthError("HTTP 404 NoSuchBucket")).toBe(false);
65 expect(isAuthError("getaddrinfo ENOTFOUND minio")).toBe(false);
66 });
67});
68
69describe("recordUnavailable — unreachable is mode-dependent, auth is not", () => {
70 it("dev mode: a non-auth unreachable is a report-only SKIP (not a pass)", () => {
71 const out: CheckResult[] = [];
72 recordUnavailable(out, "WAL", new Error("fetch failed: ECONNREFUSED"), "10m", false);
73 expect(out).toHaveLength(1);
74 expect(out[0]!.status).toBe("SKIP");
75 expect(out[0]!.detail).toContain("report-only skip");
76 expect(out[0]!.detail).toContain("NOT a pass");
77 });
78 it("strict mode: the SAME unreachable is a FAIL (the stack is expected up)", () => {
79 const out: CheckResult[] = [];
80 recordUnavailable(out, "WAL", new Error("fetch failed: ECONNREFUSED"), "10m", true);
81 expect(out[0]!.status).toBe("FAIL");
82 expect(out[0]!.detail).toContain("UNAVAILABLE (strict)");
83 });
84 it("an AUTH error is a FAIL in BOTH modes (never silent, even on a dev laptop)", () => {
85 for (const strict of [false, true]) {
86 const out: CheckResult[] = [];
87 recordUnavailable(out, "base", new Error("HTTP 403 SignatureDoesNotMatch"), "26h", strict);
88 expect(out[0]!.status).toBe("FAIL");
89 expect(out[0]!.detail).toContain("AUTH ERROR");
90 }
91 });
92});
93
94describe("verdictFor — the exit-code contract (the gate)", () => {
95 it("any FAIL → exit 1, even mixed with skips", () => {
96 const v = verdictFor([r("PASS", "base"), r("FAIL", "WAL", 99), r("SKIP", "OS")]);
97 expect(v.exitCode).toBe(1);
98 expect(v.failures.map((f) => f.label)).toEqual(["WAL"]);
99 expect(v.message).toContain("1 check(s) not healthy");
100 });
101 it("SKIPs only (dev) → exit 0, a report-only OK (loud, never a silent pass)", () => {
102 const v = verdictFor([r("SKIP", "base"), r("SKIP", "WAL"), r("SKIP", "OS")]);
103 expect(v.exitCode).toBe(0);
104 expect(v.skips).toHaveLength(3);
105 expect(v.message).toContain("report-only skip(s)");
106 expect(v.message).toContain("No failures");
107 });
108 it("all PASS → exit 0, healthy", () => {
109 const v = verdictFor([r("PASS", "base"), r("PASS", "WAL"), r("PASS", "OS")]);
110 expect(v.exitCode).toBe(0);
111 expect(v.message).toBe("OK — all 3 checks healthy");
112 });
113 it("empty result set is healthy (exit 0) — never a crash", () => {
114 expect(verdictFor([]).exitCode).toBe(0);
115 });
116});
117
118describe("the REACHABLE-but-STALE invariant (the core of the gate)", () => {
119
120
121
122 const check = (ageMs: number, limitMs: number): CheckResult["status"] =>
123 ageMs <= limitMs ? "PASS" : "FAIL";
124
125 it("fresh marker passes; just-over-limit is FAIL", () => {
126 expect(check(25.9 * HOUR, 26 * HOUR)).toBe("PASS");
127 expect(check(26 * HOUR, 26 * HOUR)).toBe("PASS");
128 expect(check(26.1 * HOUR, 26 * HOUR)).toBe("FAIL");
129 });
130 it("a stale check therefore fails the release (exit 1)", () => {
131 const v = verdictFor([r("PASS", "base"), r("FAIL", "WAL", 26.1 * HOUR), r("PASS", "OS")]);
132 expect(v.exitCode).toBe(1);
133 });
134});
135