mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition.
This commit is contained in:
1 parent
953c64f7a4
commit
d3c672cc3f
56 files changed
+3042
-1611
No files matched your search
@@ -20,7 +20,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
|
||||
vi.mock("../recovery-kit-card", () => ({ RecoveryKitCard: () => null }));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ColumnCoverage, EncryptionSection } from "../encryption-section";
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* The key backup step and its dashboard banner.
|
||||
*
|
||||
* 1. the card shows the key id and fingerprint and never anything shaped
|
||||
* like key material; the platform hint picks the open tab;
|
||||
* 2. a due step offers the confirmation, a done step does not;
|
||||
* 3. the banner renders only for an admin and only while the step is due;
|
||||
* 4. the copy resolves in German too.
|
||||
*
|
||||
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
|
||||
* render the confirm button unconditionally and case 2 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { KeyBackupCard } from "@/components/admin/key-backup-card";
|
||||
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
|
||||
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/encryption",
|
||||
}));
|
||||
|
||||
const DUE: KeyBackupStatus = {
|
||||
due: true,
|
||||
activeKeyId: "v1",
|
||||
fingerprint: "a1b2c3d4e5f6",
|
||||
confirmedAt: null,
|
||||
confirmedKeyId: null,
|
||||
confirmedFingerprint: null,
|
||||
confirmedBy: null,
|
||||
platformHint: "truenas",
|
||||
};
|
||||
|
||||
const DONE: KeyBackupStatus = {
|
||||
...DUE,
|
||||
due: false,
|
||||
confirmedAt: "2026-10-03T10:00:00.000Z",
|
||||
confirmedKeyId: "v1",
|
||||
confirmedFingerprint: "a1b2c3d4e5f6",
|
||||
confirmedBy: { id: "u1", email: "admin@example.test" },
|
||||
};
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
status: KeyBackupStatus | null,
|
||||
locale: "en" | "de" = "en",
|
||||
): string {
|
||||
const client = new QueryClient();
|
||||
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("KeyBackupCard", () => {
|
||||
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
|
||||
const html = render(<KeyBackupCard />, DUE);
|
||||
expect(html).toContain("a1b2c3d4e5f6");
|
||||
expect(html).toContain(">v1<");
|
||||
expect(html).toContain("Apps, select HealthLog");
|
||||
expect(html).not.toMatch(/[0-9a-f]{64}/);
|
||||
});
|
||||
|
||||
it("offers the confirmation only while the step is due", () => {
|
||||
expect(render(<KeyBackupCard />, DUE)).toContain(
|
||||
"I have backed up this key",
|
||||
);
|
||||
const done = render(<KeyBackupCard />, DONE);
|
||||
expect(done).not.toContain("I have backed up this key");
|
||||
expect(done).toContain("admin@example.test");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
|
||||
"Sichere deinen Verschlüsselungsschlüssel",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyBackupBanner", () => {
|
||||
it("renders for an admin while the step is due", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
|
||||
'data-testid="key-backup-banner"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a non-admin, or once the step is done", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
|
||||
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* The recovery kit card and its dashboard banner.
|
||||
*
|
||||
* 1. the card lists every key with its id and fingerprint and never
|
||||
* anything shaped like key material; the platform hint picks the open
|
||||
* tab; the restore guide is linked;
|
||||
* 2. a retired key a backup still needs is listed with why, and a key this
|
||||
* server does not hold says so;
|
||||
* 3. a due kit offers the confirmation and names what the confirmation
|
||||
* does not cover, a confirmed one does not;
|
||||
* 4. the banner renders only for an admin and only while the kit is due;
|
||||
* 5. the copy resolves in German too.
|
||||
*
|
||||
* Mutation check: drop the `isAdmin` guard in the banner and case 4 goes red;
|
||||
* render the confirm button unconditionally and case 3 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DISASTER_RECOVERY_GUIDE_URL,
|
||||
RecoveryKitCard,
|
||||
} from "@/components/admin/recovery-kit-card";
|
||||
import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner";
|
||||
import type { KitKey, RecoveryKit } from "@/components/admin/use-recovery-kit";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/encryption",
|
||||
}));
|
||||
|
||||
const need = (over: Partial<KitKey["need"]> = {}): KitKey["need"] => ({
|
||||
active: false,
|
||||
configured: true,
|
||||
storedCopies: 0,
|
||||
offhostUntil: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const KEYS: KitKey[] = [
|
||||
{
|
||||
kind: "application",
|
||||
id: "v1",
|
||||
fingerprint: null,
|
||||
source: "ENCRYPTION_KEYS",
|
||||
need: need({ configured: false, storedCopies: 2 }),
|
||||
confirmed: false,
|
||||
problem: "not_configured",
|
||||
},
|
||||
{
|
||||
kind: "application",
|
||||
id: "v2",
|
||||
fingerprint: "a1b2c3d4e5f6",
|
||||
source: "ENCRYPTION_KEYS",
|
||||
need: need({ active: true }),
|
||||
confirmed: false,
|
||||
problem: null,
|
||||
},
|
||||
{
|
||||
kind: "backup",
|
||||
id: "0f0e0d0c0b0a",
|
||||
fingerprint: "0f0e0d0c0b0a",
|
||||
source: "BACKUP_ENCRYPTION_KEY",
|
||||
need: need({ active: true }),
|
||||
confirmed: true,
|
||||
problem: null,
|
||||
},
|
||||
{
|
||||
kind: "token",
|
||||
id: "API_TOKEN_HMAC_KEY",
|
||||
fingerprint: "112233445566",
|
||||
source: "API_TOKEN_HMAC_KEY",
|
||||
need: need({ active: true }),
|
||||
confirmed: true,
|
||||
problem: null,
|
||||
},
|
||||
];
|
||||
|
||||
const DUE: RecoveryKit = {
|
||||
keys: KEYS,
|
||||
due: true,
|
||||
unconfirmed: [
|
||||
{ kind: "application", id: "v1" },
|
||||
{ kind: "application", id: "v2" },
|
||||
],
|
||||
confirmedAt: "2026-10-03T10:00:00.000Z",
|
||||
confirmedBy: null,
|
||||
platformHint: "truenas",
|
||||
};
|
||||
|
||||
const DONE: RecoveryKit = {
|
||||
...DUE,
|
||||
keys: KEYS.map((k) => ({ ...k, confirmed: true })),
|
||||
due: false,
|
||||
unconfirmed: [],
|
||||
confirmedBy: { id: "u1", email: "admin@example.test" },
|
||||
};
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
kit: RecoveryKit | null,
|
||||
locale: "en" | "de" = "en",
|
||||
): string {
|
||||
const client = new QueryClient();
|
||||
if (kit) client.setQueryData(queryKeys.adminRecoveryKit(), kit);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RecoveryKitCard", () => {
|
||||
it("lists every key by id and fingerprint, opens the hinted platform, links the guide, and shows no key material", () => {
|
||||
const html = render(<RecoveryKitCard />, DUE);
|
||||
for (const k of KEYS) {
|
||||
expect(html).toContain(
|
||||
`data-testid="recovery-kit-key-${k.kind}-${k.id}"`,
|
||||
);
|
||||
}
|
||||
expect(html).toContain("a1b2c3d4e5f6");
|
||||
expect(html).toContain("112233445566");
|
||||
expect(html).toContain("Apps, select HealthLog");
|
||||
expect(html).toContain(DISASTER_RECOVERY_GUIDE_URL);
|
||||
expect(html).not.toMatch(/[0-9a-f]{64}/);
|
||||
});
|
||||
|
||||
it("says why a retired key is listed and that this server does not hold it", () => {
|
||||
const html = render(<RecoveryKitCard />, DUE);
|
||||
expect(html).toContain("Needed by 2 stored copies.");
|
||||
expect(html).toContain("Not on this server");
|
||||
expect(html).toContain("Application key v1");
|
||||
});
|
||||
|
||||
it("offers the confirmation only while the kit is due, naming what is not covered", () => {
|
||||
const due = render(<RecoveryKitCard />, DUE);
|
||||
expect(due).toContain("I have a copy of every key");
|
||||
expect(due).toContain("v1, v2");
|
||||
const done = render(<RecoveryKitCard />, DONE);
|
||||
expect(done).not.toContain("I have a copy of every key");
|
||||
expect(done).toContain("admin@example.test");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render(<RecoveryKitCard />, DUE, "de")).toContain(
|
||||
"Wiederherstellungs-Kit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RecoveryKitBanner", () => {
|
||||
it("renders for an admin while the kit is due", () => {
|
||||
expect(render(<RecoveryKitBanner isAdmin />, DUE)).toContain(
|
||||
'data-testid="recovery-kit-banner"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a non-admin, or once the kit is confirmed", () => {
|
||||
expect(render(<RecoveryKitBanner isAdmin={false} />, DUE)).toBe("");
|
||||
expect(render(<RecoveryKitBanner isAdmin />, DONE)).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user