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.
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
/**
|
|
* The restore preview is worked out while a copy is written and answered from
|
|
* the row, without opening the copy (#1031).
|
|
*
|
|
* Both ends and the pipe: the copies are written by the real weekly job and
|
|
* the real upload route, the preview route reads them back, and what it
|
|
* answers is held against a full read of the stored copy, which is how the
|
|
* preview used to be taken. A copy from before previews is read whole once
|
|
* and its preview kept; a key dropped after the copy was written is still
|
|
* named, because the verdict is taken again at every read.
|
|
*
|
|
* Mutations that must turn this red: drop the `preview` the weekly job or
|
|
* the upload route hands `storeBackupBlob` (no preview on the row, the route
|
|
* opens the copy), or
|
|
* answer from the stored verdict instead of taking it again (the dropped key
|
|
* is not named).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { readStoredBackup } from "./stored-backup-read";
|
|
|
|
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 opened = vi.hoisted(() => ({ count: 0 }));
|
|
vi.mock("@/lib/export/stored-backup", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("@/lib/export/stored-backup")>();
|
|
return {
|
|
...actual,
|
|
openStoredBackup: (...a: Parameters<typeof actual.openStoredBackup>) => {
|
|
opened.count += 1;
|
|
return actual.openStoredBackup(...a);
|
|
},
|
|
};
|
|
});
|
|
|
|
const saved = {
|
|
key: process.env.ENCRYPTION_KEY,
|
|
keys: process.env.ENCRYPTION_KEYS,
|
|
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
|
};
|
|
|
|
function useKeys(keys: Record<string, string>, active: string) {
|
|
process.env.ENCRYPTION_KEY = "";
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
|
_resetCryptoCacheForTests();
|
|
}
|
|
const OLD = "11".repeat(32);
|
|
const CUR = "22".repeat(32);
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
opened.count = 0;
|
|
useKeys({ old: OLD, cur: CUR }, "cur");
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
|
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
|
_resetCryptoCacheForTests();
|
|
});
|
|
|
|
async function seedAdmin() {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "preview-admin",
|
|
email: "preview-admin@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);
|
|
return admin;
|
|
}
|
|
|
|
async function preview(id: string) {
|
|
const { GET } = await import("@/app/api/admin/backups/[id]/summary/route");
|
|
const res = await GET(
|
|
new Request(`http://localhost/api/admin/backups/${id}/summary`) as never,
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
return {
|
|
status: res.status,
|
|
body: (await res.json()) as {
|
|
data: { summary: Record<string, unknown> } | null;
|
|
error: string | null;
|
|
meta?: { errorCode?: string; keyIds?: string[] };
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The counts a full read of the stored copy gives: the old preview. */
|
|
async function fullReadSummary(id: string) {
|
|
const { parseBackupPayload } = await import("@/lib/validations/backup");
|
|
const { summarizeBackup } = await import("@/lib/validations/backup-summary");
|
|
const opensBefore = opened.count;
|
|
const payload = parseBackupPayload(
|
|
JSON.parse(await readStoredBackup(getPrismaClient(), id)),
|
|
);
|
|
opened.count = opensBefore;
|
|
return summarizeBackup(payload);
|
|
}
|
|
|
|
describe("restore preview kept with the copy", () => {
|
|
it("the weekly backup writes it, and the preview answers from it", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdmin();
|
|
await prisma.measurement.createMany({
|
|
data: Array.from({ length: 25 }, (_, i) => ({
|
|
userId: admin.id,
|
|
type: "PULSE" as const,
|
|
value: 60 + i,
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)),
|
|
source: "MANUAL" as const,
|
|
})),
|
|
});
|
|
|
|
const { handleDataBackup } =
|
|
await import("@/lib/jobs/reminder/backup-handlers");
|
|
await handleDataBackup([]);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { userId: admin.id, type: "WEEKLY_AUTO" },
|
|
});
|
|
// The pass marked the account started and then finished: only a process
|
|
// that died under it would leave the start alone.
|
|
const attempt = await prisma.backupPassAttempt.findUniqueOrThrow({
|
|
where: { userId_pass: { userId: admin.id, pass: "data-backup" } },
|
|
});
|
|
expect(attempt.finishedAt).not.toBeNull();
|
|
expect(attempt.finishedAt!.getTime()).toBeGreaterThanOrEqual(
|
|
attempt.startedAt.getTime(),
|
|
);
|
|
expect(row.preview).toMatchObject({
|
|
version: 1,
|
|
copy: `chunks:${row.chunkStreamId}:${row.chunkCount}`,
|
|
summary: { measurements: 25 },
|
|
});
|
|
|
|
const res = await preview(row.id);
|
|
expect(res.status).toBe(200);
|
|
expect(opened.count).toBe(0);
|
|
expect(res.body.data!.summary).toEqual(await fullReadSummary(row.id));
|
|
expect(res.body.data!.summary.measurements).toBe(25);
|
|
});
|
|
|
|
it("an upload writes it, and a key dropped later is still named", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdmin();
|
|
// A note written under `old`, as a copy taken before a rotation carries.
|
|
useKeys({ old: OLD, cur: CUR }, "old");
|
|
const note = Buffer.from(encrypt("before"), "utf8").toString("base64");
|
|
useKeys({ old: OLD, cur: CUR }, "cur");
|
|
const file = JSON.stringify({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-09-20T00:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: Array.from({ length: 30 }, (_, i) => ({
|
|
id: `m-${i}`,
|
|
type: "PULSE",
|
|
value: 60 + (i % 30),
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)).toISOString(),
|
|
source: "MANUAL",
|
|
...(i === 3 ? { notesEncrypted: note } : {}),
|
|
})),
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/admin/backups/upload/route");
|
|
const uploaded = await POST(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: file,
|
|
}) as never,
|
|
);
|
|
expect(uploaded.status).toBe(201);
|
|
const { data: upload } = (await uploaded.json()) as {
|
|
data: { id: string; summary: Record<string, unknown> };
|
|
};
|
|
const row = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: upload.id },
|
|
});
|
|
expect(row.preview).toMatchObject({ summary: { measurements: 30 } });
|
|
|
|
const res = await preview(upload.id);
|
|
expect(res.status).toBe(200);
|
|
expect(opened.count).toBe(0);
|
|
expect(res.body.data!.summary).toEqual(upload.summary);
|
|
expect(res.body.data!.summary).toEqual(await fullReadSummary(upload.id));
|
|
|
|
// The operator drops `old` after the copy was written.
|
|
useKeys({ cur: CUR }, "cur");
|
|
const refused = await preview(upload.id);
|
|
expect(refused.status).toBe(422);
|
|
expect(refused.body.meta).toMatchObject({
|
|
errorCode: "backup.key.missing",
|
|
keyIds: ["old"],
|
|
});
|
|
expect(opened.count).toBe(0);
|
|
});
|
|
|
|
it("a copy from before previews is read whole once, then answered from the row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdmin();
|
|
await prisma.measurement.createMany({
|
|
data: Array.from({ length: 5 }, (_, i) => ({
|
|
userId: admin.id,
|
|
type: "PULSE" as const,
|
|
value: 60 + i,
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 8, 1, 0, i)),
|
|
source: "MANUAL" as const,
|
|
})),
|
|
});
|
|
const { handleDataBackup } =
|
|
await import("@/lib/jobs/reminder/backup-handlers");
|
|
await handleDataBackup([]);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { userId: admin.id },
|
|
});
|
|
// As an earlier release left it.
|
|
await prisma.dataBackup.update({
|
|
where: { id: row.id },
|
|
data: { preview: Prisma.DbNull },
|
|
});
|
|
|
|
const first = await preview(row.id);
|
|
expect(first.status).toBe(200);
|
|
expect(opened.count).toBe(1);
|
|
expect(first.body.data!.summary.measurements).toBe(5);
|
|
await vi.waitFor(async () => {
|
|
const kept = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: row.id },
|
|
});
|
|
expect(kept.preview).toMatchObject({ summary: { measurements: 5 } });
|
|
});
|
|
|
|
const second = await preview(row.id);
|
|
expect(second.status).toBe(200);
|
|
expect(opened.count).toBe(1);
|
|
expect(second.body.data!.summary).toEqual(first.body.data!.summary);
|
|
});
|
|
|
|
it("a replaced copy is described by its own preview", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdmin();
|
|
const { handleDataBackup } =
|
|
await import("@/lib/jobs/reminder/backup-handlers");
|
|
await handleDataBackup([]);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: new Date(Date.UTC(2026, 8, 2)),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
await handleDataBackup([]);
|
|
const row = await prisma.dataBackup.findFirstOrThrow({
|
|
where: { userId: admin.id },
|
|
});
|
|
expect(row.preview).toMatchObject({
|
|
copy: `chunks:${row.chunkStreamId}:${row.chunkCount}`,
|
|
summary: { measurements: 1 },
|
|
});
|
|
const res = await preview(row.id);
|
|
expect(res.body.data!.summary.measurements).toBe(1);
|
|
expect(opened.count).toBe(0);
|
|
});
|
|
|
|
it("a single stored value keeps its preview when it is converted to pieces", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdmin();
|
|
// The oldest stored form: the whole file as one encrypted value.
|
|
const row = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "MANUAL_UPLOAD_1",
|
|
data: encrypt(
|
|
JSON.stringify({
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-09T10:00:00.000Z",
|
|
userId: admin.id,
|
|
measurements: [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: "2026-05-08T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
});
|
|
|
|
const first = await preview(row.id);
|
|
expect(first.status).toBe(200);
|
|
expect(opened.count).toBe(1);
|
|
await vi.waitFor(async () => {
|
|
const kept = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: row.id },
|
|
});
|
|
expect(kept.preview).toMatchObject({ summary: { measurements: 1 } });
|
|
});
|
|
|
|
const { convertSingleValueBackup } =
|
|
await import("@/lib/export/store-backup-blob");
|
|
expect(await convertSingleValueBackup(prisma, row.id)).toBe("converted");
|
|
const converted = await prisma.dataBackup.findUniqueOrThrow({
|
|
where: { id: row.id },
|
|
});
|
|
expect(converted.preview).toMatchObject({
|
|
copy: `chunks:${converted.chunkStreamId}:${converted.chunkCount}`,
|
|
summary: { measurements: 1 },
|
|
});
|
|
|
|
const second = await preview(row.id);
|
|
expect(second.status).toBe(200);
|
|
expect(opened.count).toBe(1);
|
|
expect(second.body.data!.summary).toEqual(first.body.data!.summary);
|
|
});
|
|
});
|