/** * TASK-0135 (P8 hardening) — backup/restore VALIDATION of the DR health gate. * * `scripts/backup-health.ts` is the independent verifier the release pipeline * runs as a HARD GATE (stage 9) — it fails the release when a backup stopped * working (NFR-B1…B7: RPO ≤ 5 min, RTO ≤ 1 h). Its decision matrix is the * safety-critical part; the live S3/OpenSearch fetches are not. This suite * validates that matrix against the pure, side-effect-free decision module * (`scripts/backup-health-decisions.ts`) so a regression in the verdict * logic — a stale backup quietly passing, an auth failure going silent, a * strict-mode unreachable no longer failing — is caught here, not in prod. * * The contract (script header + deployment.md §8): * 1. REACHABLE-but-STALE marker → FAIL, exit 1, in EVERY mode. * 2. AUTH error (403 / bad creds)→ FAIL, exit 1, in EVERY mode (never silent). * 3. UNREACHABLE, dev mode → SKIP, exit 0, report-only (NOT a pass). * 4. UNREACHABLE, strict/staging → FAIL, exit 1 (the stack is expected up). */ import { describe, expect, it } from "vitest"; import { ageMsSince, fmtAge, isAuthError, recordUnavailable, verdictFor, type CheckResult } from "../../scripts/backup-health-decisions.js"; const NOW = new Date("2026-10-12T12:00:00.000Z"); const HOUR = 3_600_000; const r = (status: CheckResult["status"], label = "X", ageMs: number | null = null): CheckResult => ({ label, status, detail: `${label} (${status})`, ageMs }); describe("ageMsSince / fmtAge", () => { it("measures age from an ISO timestamp", () => { expect(ageMsSince("2026-10-12T11:00:00.000Z", NOW)).toBe(1 * HOUR); expect(ageMsSince("2026-10-12T12:00:00.000Z", NOW)).toBe(0); }); it("formats minutes under an hour, one-decimal hours above", () => { expect(fmtAge(5 * 60_000)).toBe("5m"); expect(fmtAge(59 * 60_000)).toBe("59m"); expect(fmtAge(60 * 60_000)).toBe("1.0h"); expect(fmtAge(25.5 * HOUR)).toBe("25.5h"); }); }); describe("isAuthError — a mis-credentialed identity is ALWAYS a failure", () => { it("matches the S3/OS auth failure strings", () => { expect(isAuthError("HTTP 403 Forbidden")).toBe(true); expect(isAuthError("SignatureDoesNotMatch")).toBe(true); expect(isAuthError("AccessDenied")).toBe(true); expect(isAuthError("InvalidAccessKeyId")).toBe(true); expect(isAuthError("InvalidClientTokenId")).toBe(true); expect(isAuthError("An error occurred (Credentials) when calling...")).toBe(true); expect(isAuthError("401 unauthorized")).toBe(true); }); it("does NOT match non-auth unreachability", () => { expect(isAuthError("fetch failed: ECONNREFUSED 127.0.0.1:9000")).toBe(false); expect(isAuthError("HTTP 404 NoSuchBucket")).toBe(false); expect(isAuthError("getaddrinfo ENOTFOUND minio")).toBe(false); }); }); describe("recordUnavailable — unreachable is mode-dependent, auth is not", () => { it("dev mode: a non-auth unreachable is a report-only SKIP (not a pass)", () => { const out: CheckResult[] = []; recordUnavailable(out, "WAL", new Error("fetch failed: ECONNREFUSED"), "10m", /*strict*/ false); expect(out).toHaveLength(1); expect(out[0]!.status).toBe("SKIP"); expect(out[0]!.detail).toContain("report-only skip"); expect(out[0]!.detail).toContain("NOT a pass"); }); it("strict mode: the SAME unreachable is a FAIL (the stack is expected up)", () => { const out: CheckResult[] = []; recordUnavailable(out, "WAL", new Error("fetch failed: ECONNREFUSED"), "10m", /*strict*/ true); expect(out[0]!.status).toBe("FAIL"); expect(out[0]!.detail).toContain("UNAVAILABLE (strict)"); }); it("an AUTH error is a FAIL in BOTH modes (never silent, even on a dev laptop)", () => { for (const strict of [false, true]) { const out: CheckResult[] = []; recordUnavailable(out, "base", new Error("HTTP 403 SignatureDoesNotMatch"), "26h", strict); expect(out[0]!.status).toBe("FAIL"); expect(out[0]!.detail).toContain("AUTH ERROR"); } }); }); describe("verdictFor — the exit-code contract (the gate)", () => { it("any FAIL → exit 1, even mixed with skips", () => { const v = verdictFor([r("PASS", "base"), r("FAIL", "WAL", 99), r("SKIP", "OS")]); expect(v.exitCode).toBe(1); expect(v.failures.map((f) => f.label)).toEqual(["WAL"]); expect(v.message).toContain("1 check(s) not healthy"); }); it("SKIPs only (dev) → exit 0, a report-only OK (loud, never a silent pass)", () => { const v = verdictFor([r("SKIP", "base"), r("SKIP", "WAL"), r("SKIP", "OS")]); expect(v.exitCode).toBe(0); expect(v.skips).toHaveLength(3); expect(v.message).toContain("report-only skip(s)"); expect(v.message).toContain("No failures"); }); it("all PASS → exit 0, healthy", () => { const v = verdictFor([r("PASS", "base"), r("PASS", "WAL"), r("PASS", "OS")]); expect(v.exitCode).toBe(0); expect(v.message).toBe("OK — all 3 checks healthy"); }); it("empty result set is healthy (exit 0) — never a crash", () => { expect(verdictFor([]).exitCode).toBe(0); }); }); describe("the REACHABLE-but-STALE invariant (the core of the gate)", () => { // The live checks push PASS/FAIL based on `age <= limit`; a stale marker // (age > limit) is a FAIL in EVERY mode. This guards that the threshold // comparison is inclusive on the limit and flips to FAIL just past it. const check = (ageMs: number, limitMs: number): CheckResult["status"] => ageMs <= limitMs ? "PASS" : "FAIL"; it("fresh marker passes; just-over-limit is FAIL", () => { expect(check(25.9 * HOUR, 26 * HOUR)).toBe("PASS"); expect(check(26 * HOUR, 26 * HOUR)).toBe("PASS"); // inclusive on the limit expect(check(26.1 * HOUR, 26 * HOUR)).toBe("FAIL"); // stale → FAIL, every mode }); it("a stale check therefore fails the release (exit 1)", () => { const v = verdictFor([r("PASS", "base"), r("FAIL", "WAL", 26.1 * HOUR), r("PASS", "OS")]); expect(v.exitCode).toBe(1); }); });