From d7bc873691c4afa7754a748403fb253d56e1d404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 08:51:00 +0200 Subject: [PATCH] fix(backup): answer an upload too large to store with 413 An uploaded file whose encrypted copy passes the size one stored backup may have on this host (a fifth of the heap limit) stopped the store with BackupBlobTooLargeError, which the route let through as a 500 and reported to the error tracker. It is now a 413 with the numbers and the remedy in the message and meta.errorCode backup.upload.too_large, and the refusal is audited like the others. The store rolls back, so nothing is kept. --- docs/api/openapi.yaml | 4 +- src/app/api/admin/backups/upload/route.ts | 19 +++ src/lib/export/backup-blob.ts | 3 + src/lib/openapi/error-codes.ts | 1 + .../admin-backups-upload-too-large.test.ts | 114 ++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tests/integration/admin-backups-upload-too-large.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 64eb574d1..60ab169cf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30494,8 +30494,8 @@ components: anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.payload.undecryptable, backup.restore.active, backup.section.missing; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, + backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; + coach — coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 8110b4bac..3b4a51811 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -38,6 +38,10 @@ import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan"; +import { + BACKUP_UPLOAD_TOO_LARGE_CODE, + BackupBlobTooLargeError, +} from "@/lib/export/backup-blob"; import { storeBackupBlob } from "@/lib/export/store-backup-blob"; import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup"; import { annotate } from "@/lib/logging/context"; @@ -332,6 +336,21 @@ export const POST = apiHandler(async (request: NextRequest) => { await denied(err.details.reason as string, err.details); return apiError(err.answer, err.status, err.meta); } + // The file was fine; its encrypted copy is larger than one stored backup + // may be on this host (a fifth of the heap limit). That is the operator's + // to act on, with the numbers in the message, and not a server fault for + // the error reporter. The store rolled back, so nothing was kept. + if (err instanceof BackupBlobTooLargeError) { + await denied("stored_copy_too_large", { + bytes: err.bytes, + limitBytes: err.limitBytes, + }); + return apiError( + `This backup is too large to store on this server. ${err.message}`, + 413, + { errorCode: BACKUP_UPLOAD_TOO_LARGE_CODE }, + ); + } throw err; } diff --git a/src/lib/export/backup-blob.ts b/src/lib/export/backup-blob.ts index 0c152ba45..745832139 100644 --- a/src/lib/export/backup-blob.ts +++ b/src/lib/export/backup-blob.ts @@ -99,6 +99,9 @@ export function defaultBackupBlobLimit(): number { * is about the record, and it is true: raising the heap raises the limit with * it, since the limit is derived from the heap. */ +/** The stable code of the 413 an upload too large to store is answered with. */ +export const BACKUP_UPLOAD_TOO_LARGE_CODE = "backup.upload.too_large"; + export class BackupBlobTooLargeError extends Error { readonly bytes: number; readonly limitBytes: number; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7b94581ef..b3a1e4d1b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -69,6 +69,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "backup.payload.undecryptable", "backup.restore.active", "backup.section.missing", + "backup.upload.too_large", ], coach: [ "coach.budget.exceeded", diff --git a/tests/integration/admin-backups-upload-too-large.test.ts b/tests/integration/admin-backups-upload-too-large.test.ts new file mode 100644 index 000000000..f288cfa02 --- /dev/null +++ b/tests/integration/admin-backups-upload-too-large.test.ts @@ -0,0 +1,114 @@ +/** + * An upload whose encrypted copy is too large to store is a 413, not a 500. + * + * One stored backup may take a fifth of the process's heap limit + * (`defaultBackupBlobLimit`); past it the packer stops with + * `BackupBlobTooLargeError`. The upload route let that escape as a server + * fault, so the operator saw "internal error" and the error reporter was + * paged for a file that is simply too large for this host. The limit is + * lowered here through the store's own option so a small file crosses it, + * and everything else runs as it does in production. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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), +})); +vi.mock("@/lib/export/store-backup-blob", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + storeBackupBlob: ((prisma, input, producer) => + actual.storeBackupBlob(prisma, input, producer, { + maxBytes: 256, + })) as typeof actual.storeBackupBlob, + }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("POST /api/admin/backups/upload — a copy too large to store", () => { + it("answers 413 with the reason and stores nothing", async () => { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "upload-large-admin", + email: "upload-large-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); + + const file = JSON.stringify({ + schemaVersion: "2", + exportedAt: "2026-09-20T00:00:00.000Z", + userId: admin.id, + measurements: Array.from({ length: 200 }, (_, 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", + })), + }); + + const { POST } = await import("@/app/api/admin/backups/upload/route"); + const res = await POST( + new Request("http://localhost/api/admin/backups/upload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: file, + }) as never, + ); + + expect(res.status).toBe(413); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.error).toContain("too large to store"); + expect(body.meta?.errorCode).toBe("backup.upload.too_large"); + expect(await prisma.dataBackup.count()).toBe(0); + const denied = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.upload.denied" }, + }); + expect(JSON.parse(denied.details ?? "{}").reason).toBe( + "stored_copy_too_large", + ); + }); +});