mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
3abbad048e
commit
d7bc873691
5 files changed
+139
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -69,6 +69,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"backup.payload.undecryptable",
|
||||
"backup.restore.active",
|
||||
"backup.section.missing",
|
||||
"backup.upload.too_large",
|
||||
],
|
||||
coach: [
|
||||
"coach.budget.exceeded",
|
||||
|
||||
@@ -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<typeof import("@/lib/export/store-backup-blob")>();
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user