mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
/**
|
|
* A stored backup records the keys its content needs, and the encryption view
|
|
* says a retired key is still needed while any backup lists it.
|
|
*
|
|
* Both ends and the pipe: the copy is written by the real `storeBackupBlob`,
|
|
* the key ids are read from the JSON on its way into the pieces, and the
|
|
* admin status route reads them back. The case the view used to get wrong is
|
|
* the one asserted: every row rotated, the badge said the old key was safe to
|
|
* drop, and a backup still needed it.
|
|
*
|
|
* Mutations that must turn this red: drop the scanner from `storeBackupBlob`
|
|
* (nothing is recorded, the view has no key to name), or leave
|
|
* `retiredKeysStillNeeded` out of `safeToDropRetiredKeys` (the badge says
|
|
* safe again).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
const saved = {
|
|
key: process.env.ENCRYPTION_KEY,
|
|
keys: process.env.ENCRYPTION_KEYS,
|
|
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
|
};
|
|
|
|
function useKeys(active: string) {
|
|
process.env.ENCRYPTION_KEY = "";
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
|
old: "11".repeat(32),
|
|
cur: "22".repeat(32),
|
|
});
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
|
_resetCryptoCacheForTests();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
|
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
|
_resetCryptoCacheForTests();
|
|
});
|
|
|
|
describe("the keys stored backups still need", () => {
|
|
it("names a retired key a backup's content was written under", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: { username: "keys-admin", email: "k@example.test", role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
|
|
// A copy taken before the rotation: its note was written under "old".
|
|
useKeys("old");
|
|
const note = Buffer.from(encrypt("before"), "utf8").toString("base64");
|
|
// The copy itself is stored after the rotation, so its envelope is
|
|
// sealed under "cur" and only its content needs "old".
|
|
useKeys("cur");
|
|
await storeBackupBlob(
|
|
prisma,
|
|
{ userId: admin.id, type: "MANUAL_UPLOAD_KEYS" },
|
|
async (write) => {
|
|
await write('{"userId":"x","measurements":[{"notesEncrypted":');
|
|
await write(`"${note}"}]}`);
|
|
},
|
|
);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { type: "MANUAL_UPLOAD_KEYS" },
|
|
});
|
|
expect(row.innerKeyIds).toEqual(["old"]);
|
|
expect(row.innerKeyIdsRecorded).toBe(true);
|
|
|
|
const { GET } = await import("@/app/api/admin/encryption/status/route");
|
|
const res = await GET();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()).data as {
|
|
rotationComplete: boolean;
|
|
safeToDropRetiredKeys: boolean;
|
|
backups: {
|
|
stored: Array<{ keyId: string; copies: number }>;
|
|
retiredKeysStillNeeded: string[];
|
|
};
|
|
};
|
|
expect(body.backups.stored).toEqual([
|
|
expect.objectContaining({ keyId: "old", copies: 1 }),
|
|
]);
|
|
expect(body.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
expect(body.safeToDropRetiredKeys).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the keys the database dumps still need", () => {
|
|
// Every row rotated and no stored copy on the old key: the view called the
|
|
// old key safe to drop while the dumps from before the rotation, the
|
|
// pre-migration one that is the way back from an update among them, could
|
|
// only be restored with it.
|
|
it("names the key a dump on this server or in the bucket was written under", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: { username: "dump-admin", email: "d@example.test", role: "ADMIN" },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
|
|
const { tmpdir } = await import("node:os");
|
|
const { join } = await import("node:path");
|
|
const { buildManifest, dumpBaseName } =
|
|
await import("@/lib/database-backup/dump-core.mjs");
|
|
const dir = mkdtempSync(join(tmpdir(), "hl-key-needs-"));
|
|
const savedDir = process.env.DB_BACKUP_DIR;
|
|
process.env.DB_BACKUP_DIR = dir;
|
|
try {
|
|
// A pre-migration dump taken while "old" was the active key.
|
|
useKeys("old");
|
|
const base = dumpBaseName(
|
|
"pre-migration",
|
|
new Date(Date.now() - 86_400_000),
|
|
);
|
|
writeFileSync(join(dir, `${base}.dump`), "PGDMP");
|
|
writeFileSync(
|
|
join(dir, `${base}.manifest.json`),
|
|
JSON.stringify(
|
|
buildManifest({
|
|
kind: "pre-migration",
|
|
file: `${base}.dump`,
|
|
createdAt: new Date(),
|
|
latestMigration: null,
|
|
server: { version: "18", versionNum: 180000 },
|
|
pgDump: null,
|
|
archive: { bytes: 5, sha256: "x", entries: 1 },
|
|
rowCounts: {},
|
|
env: process.env,
|
|
}),
|
|
),
|
|
);
|
|
useKeys("cur");
|
|
|
|
const { GET } = await import("@/app/api/admin/encryption/status/route");
|
|
const read = async () =>
|
|
(await (await GET()).json()).data as {
|
|
safeToDropRetiredKeys: boolean;
|
|
backups: {
|
|
dumps: Array<{ keyId: string; local: number }>;
|
|
retiredKeysStillNeeded: string[];
|
|
};
|
|
};
|
|
const local = await read();
|
|
expect(local.backups.dumps).toEqual([
|
|
expect.objectContaining({ keyId: "old", local: 1 }),
|
|
]);
|
|
expect(local.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
expect(local.safeToDropRetiredKeys).toBe(false);
|
|
|
|
// Gone from this server; its off-host copy, written under "old", is
|
|
// still in a bucket whose rule for database/ nobody could read.
|
|
rmSync(join(dir, `${base}.dump`));
|
|
rmSync(join(dir, `${base}.manifest.json`));
|
|
await prisma.databaseBackupRun.create({
|
|
data: {
|
|
startedAt: new Date(Date.now() - 86_400_000),
|
|
finishedAt: new Date(Date.now() - 86_000_000),
|
|
status: "succeeded",
|
|
fileName: "healthlog-x-daily.dump",
|
|
localResult: "not_persistent",
|
|
offhostResult: "uploaded",
|
|
activeKeyId: "old",
|
|
},
|
|
});
|
|
const offhost = await read();
|
|
expect(offhost.backups.dumps).toEqual([
|
|
expect.objectContaining({ keyId: "old", local: 0 }),
|
|
]);
|
|
expect(offhost.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
|
} finally {
|
|
process.env.DB_BACKUP_DIR = savedDir ?? "";
|
|
if (savedDir === undefined) delete process.env.DB_BACKUP_DIR;
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|