Files
HealthLog/src/lib/__tests__/api-handler-key-mismatch.test.ts
T
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
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.
2026-10-03 18:06:28 +02:00

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);
});
});