mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Every configured key id now has a canary in encryption_key_canaries, checked at boot. A key that cannot open its canary, or existing data written before the canary existed, puts the process in a refusal state: every API route answers 503 encryption.key_mismatch, /api/health reports reason encryption_key_mismatch, the pages render one explanation, and neither the queue producer nor the worker starts. The process stays up so the reason is visible instead of hiding in a crash loop. The admin Encryption page gains a "Back up your encryption key" step: key id and fingerprint (never the key), where the key lives per platform, a check of a pasted copy that is compared and discarded, and a confirmation bound to the active key id and fingerprint. The overview gets a tile and admins get a dashboard banner while it is due. HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the TrueNAS self-hosting page and key backup sections in the docs.
64 lines
2.2 KiB
TypeScript
64 lines
2.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
|
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
|
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: () => null })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import { NextRequest } from "next/server";
|
|
import { apiHandler } from "@/lib/api-handler";
|
|
import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state";
|
|
|
|
const handler = vi.fn(async (_request: NextRequest) =>
|
|
Response.json({ data: "ran", error: null }),
|
|
);
|
|
const route = apiHandler(handler);
|
|
|
|
function call(path: string) {
|
|
return route(new NextRequest(`http://localhost${path}`));
|
|
}
|
|
|
|
afterEach(() => {
|
|
setKeyMismatchState(null);
|
|
handler.mockClear();
|
|
});
|
|
|
|
describe("apiHandler while the encryption key does not match", () => {
|
|
it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => {
|
|
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
|
const response = await call("/api/measurements");
|
|
expect(response.status).toBe(503);
|
|
const body = await response.json();
|
|
expect(body.data).toBeNull();
|
|
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
|
|
expect(response.headers.get("Retry-After")).toBe("300");
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still serves /api/health and /api/version", async () => {
|
|
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
|
for (const path of ["/api/health", "/api/version"]) {
|
|
const response = await call(path);
|
|
expect(response.status).toBe(200);
|
|
}
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("runs the handler when the key check passed", async () => {
|
|
const response = await call("/api/measurements");
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|