Files
HealthLog/tests/integration/admin-backups-upload.test.ts
Marc-André Bombeck 1e09f30eff fix(backup): store a backup in sealed pieces instead of one value
A stored backup was one value in data_backups.data. v1.39.1 wrote it a
piece at a time but still joined the pieces into that one value, and one
value has to pass through the app whole, so a copy was capped at a fifth of
the heap: 105 MB in the default 1 GB container. An account with 1.75
million readings is past that and could not be backed up at all (#1031).

A copy is now kept as ordered pieces of about a megabyte in the new
data_backup_chunks table (migration 0352). Each piece is sealed on its own
with AES-256-GCM, and its sealed header names the copy (a random stream id
kept on the row), its position and whether it is the last. A reader checks
every piece before it releases a byte, so a piece that is missing, moved,
altered, taken from another copy, or a copy cut short is refused with
backup.payload.undecryptable before a restore deletes anything, and the
second read during the restore checks each piece again.

The write is one transaction, so the previous copy stays whole and readable
until the new one commits. Pieces are written through the model API: a raw
query with a large parameter is kept alive by Prisma's query plan cache,
which left 160 MB of heap behind per hundred one-megabyte inserts.

Restore, preview, download, the restore job and the admin list read both
forms; copies written before this change keep their single value and still
restore. The heap-derived limit is gone. The one limit left is on storage,
BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key
rotation covers the pieces, the wipe and account deletion take them with
their backup, and the backup plan lists the table as not carried.

Measured on an account of 2.6 million readings in a 1 GB container with web
and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s,
download 25 s, restore 264 s with every reading back exactly, peak 567 MiB,
no restart.
2026-09-26 01:02:48 +02:00

307 lines
9.1 KiB
TypeScript

/**
* Integration test for `POST /api/admin/backups/upload`.
*
* Verifies:
* - 201 + a `DataBackup` row with type prefix `MANUAL_UPLOAD_` is
* created when the JSON file matches the schema.
* - the original payload round-trips through the stored backup envelope.
* - 422 when the JSON is malformed.
* - 422 when the schema validation fails (missing required field).
* - 422 when the userId on the file doesn't exist.
* - audit-log entries cover both success and denial paths.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { readStoredBackup } from "./stored-backup-read";
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);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "upload-admin",
email: "upload-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;
}
interface UploadPayloadFixture {
schemaVersion: string;
exportedAt: string;
userId: string;
measurements: Array<{
type: string;
value: number;
unit: string;
measuredAt: string;
source: string;
notes: null;
}>;
medications: Array<{
name: string;
dose: string;
active: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string;
dose: null;
}>;
}>;
intakeEvents: [];
moodEntries: [];
}
function buildPayload(userId: string): UploadPayloadFixture {
return {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
userId,
measurements: [
{
type: "WEIGHT",
value: 80.0,
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
notes: null,
},
],
medications: [
{
name: "Ramipril",
dose: "5mg",
active: true,
schedules: [
{
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: null,
},
],
},
],
intakeEvents: [],
moodEntries: [],
};
}
function buildMultipart(file: File): Request {
const fd = new FormData();
fd.append("file", file);
return new Request("http://localhost/api/admin/backups/upload", {
method: "POST",
body: fd,
});
}
describe("POST /api/admin/backups/upload", () => {
it("accepts a valid backup file and persists an encrypted DataBackup row", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload(admin.id);
const file = new File([JSON.stringify(payload)], "backup.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: string; valid: boolean } };
expect(body.data.valid).toBe(true);
const row = await prisma.dataBackup.findUnique({
where: { id: body.data.id },
});
expect(row).not.toBeNull();
expect(row?.type).toMatch(/^MANUAL_UPLOAD_\d+$/);
expect(row?.userId).toBe(admin.id);
// Round-trip the stored blob to make sure we kept the same payload we
// received. Through the envelope rather than through `decrypt` alone: an
// uploaded file is stored exactly the way the weekly worker stores one, so
// the restore path reads both without knowing where the row came from.
const decrypted = JSON.parse(await readStoredBackup(prisma, row!.id));
expect(decrypted.userId).toBe(admin.id);
expect(decrypted.measurements).toHaveLength(1);
const events = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload", userId: admin.id },
});
expect(events).toHaveLength(1);
});
it("rejects malformed JSON with 422 + audit-log denial", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const file = new File(["{not-json"], "broken.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
it("rejects schema-invalid JSON with 422 + audit-log denial", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const broken = { schemaVersion: "1", userId: admin.id }; // missing exportedAt
const file = new File([JSON.stringify(broken)], "missing.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
it("rejects payloads referencing a non-existent owner with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload("ghost-user-id");
const file = new File([JSON.stringify(payload)], "ghost.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
expect(denied[0]?.details ?? "").toContain("owner_not_found");
});
it.each([
[
"measurement type",
(payload: UploadPayloadFixture) => {
payload.measurements[0]!.type = "NOT_A_MEASUREMENT";
},
],
[
"measurement source",
(payload: UploadPayloadFixture) => {
payload.measurements[0]!.source = "NOT_A_SOURCE";
},
],
])(
"rejects an invalid %s with 422 and zero backup writes",
async (_label, mutate) => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload(admin.id);
mutate(payload);
const file = new File([JSON.stringify(payload)], "invalid-enum.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
data: null,
error: "Backup payload failed schema validation",
meta: { issues: expect.any(Array) },
});
expect(await prisma.dataBackup.count()).toBe(0);
},
);
it("rejects an incompatible schemaVersion with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = { ...buildPayload(admin.id), schemaVersion: "999" };
const file = new File([JSON.stringify(payload)], "future.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(await res.json()).toMatchObject({
data: null,
error: expect.stringContaining("not supported"),
});
expect(await prisma.dataBackup.count()).toBe(0);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
});