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", + ); + }); +});