test(documents): end-to-end vault contract against Postgres

Pins what the unit mocks cannot: the binary2 round-trip through upload and
/original with class-correct headers, the policy error contract, sha256
dedupe per user with the partial unique index live, Idempotency-Key replay
without a second row, tombstone-inclusive quota accounting, pre-link +
replace-set + kind/episode/year list filters, restore incl. both 409
shapes, bulk per-id semantics, usage with a per-user override, and the
purge job claiming only past-grace tombstones. Migration 0227 applies
clean on the fresh testcontainer as part of the ordered chain.

Tasks: W1-T1, W1-T5, W1-T6, W1-T7, W1-T8, W1-T9, W1-T10, W1-T12.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:06:33 +02:00
1 parent 8747c69156
commit b1004958c3
1 file changed
+640
+640
View File
@@ -0,0 +1,640 @@
/**
* Document vault — end-to-end contract against a real Postgres.
*
* Covers the Wave-1 hardenings the unit mocks cannot pin:
*
* 1. Upload → binary2-encrypted row + plaintext sha256; /original
* round-trips the exact bytes with the class-correct headers.
* 2. Policy error contract: 413 fileTooLarge / quotaExceeded (with limits
* in meta), 415 unsupportedType.
* 3. sha256 dedupe: a same-user re-upload returns 200 + meta.duplicate and
* the existing row id; the partial unique index holds at the DB level.
* 4. Idempotency-Key replay returns the cached response without a second
* row.
* 5. Quota accounting is tombstone-inclusive (a soft-deleted row still
* counts against usage until purged).
* 6. episodeIds pre-link + PATCH replace-set + list filters (kind[],
* episodeId, year) — and the list never leaks the blob.
* 7. Restore: clears the tombstone; 409 after purge; 409 on a live
* duplicate of the same bytes.
* 8. Bulk endpoint per-id semantics.
* 9. Usage endpoint reflects overrides + tombstone-inclusive usage.
* 10. The purge job hard-deletes only past-grace tombstones.
*/
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),
}));
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n");
async function seedVaultUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
// Opt-in module: the vault is dark until the user flips it on.
modulePreferencesJson: { inboundDocuments: true },
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
function uploadRequest(
bytes: Buffer,
filename: string,
fields: Record<string, string | string[]> = {},
headers: Record<string, string> = {},
): Request {
const payload = new Uint8Array(bytes.byteLength);
payload.set(bytes);
const formData = new FormData();
formData.append("file", new Blob([payload]), filename);
for (const [k, v] of Object.entries(fields)) {
for (const item of Array.isArray(v) ? v : [v]) formData.append(k, item);
}
return new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
headers,
});
}
type RouteCtx = { params: Promise<{ id: string }> };
const ctx = (id: string): RouteCtx => ({ params: Promise.resolve({ id }) });
async function routes() {
const list = await import("@/app/api/documents/inbound/route");
const byId = await import("@/app/api/documents/inbound/[id]/route");
const original =
await import("@/app/api/documents/inbound/[id]/original/route");
const restore =
await import("@/app/api/documents/inbound/[id]/restore/route");
const bulk = await import("@/app/api/documents/inbound/bulk/route");
const usage = await import("@/app/api/documents/inbound/usage/route");
return {
post: list.POST as unknown as (r: Request) => Promise<Response>,
get: list.GET as unknown as (r: Request) => Promise<Response>,
patch: byId.PATCH as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
del: byId.DELETE as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
getOriginal: original.GET as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
postRestore: restore.POST as unknown as (
r: Request,
c: RouteCtx,
) => Promise<Response>,
postBulk: bulk.POST as unknown as (r: Request) => Promise<Response>,
getUsage: usage.GET as unknown as (r: Request) => Promise<Response>,
};
}
function jsonRequest(url: string, method: string, body: unknown): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("document vault — upload + serve round-trip", () => {
it("stores binary2-encrypted with sha256 and serves the exact bytes back", async () => {
await seedVaultUser("vault-roundtrip");
const { post, getOriginal } = await routes();
const res = await post(
uploadRequest(PNG_1X1, "scan.png", { title: "Röntgen Knie" }),
);
expect(res.status).toBe(201);
const { data } = await res.json();
expect(data.servingClass).toBe("inline");
// At rest: binary2 codec, plaintext sha256, ciphertext ≠ plaintext.
const row = await getPrismaClient().inboundDocument.findFirstOrThrow();
expect(row.contentCodec).toBe("binary2");
expect(row.contentSha256).toMatch(/^[0-9a-f]{64}$/);
expect(Buffer.from(row.contentEncrypted).equals(PNG_1X1)).toBe(false);
// Round-trip through the serve route: exact bytes + Class A posture.
const orig = await getOriginal(
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
ctx(data.id),
);
expect(orig.status).toBe(200);
expect(orig.headers.get("Content-Type")).toBe("image/png");
expect(orig.headers.get("Content-Disposition")).toContain("inline");
expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(orig.headers.get("Content-Security-Policy")).toBe("sandbox");
const served = Buffer.from(await orig.arrayBuffer());
expect(served.equals(PNG_1X1)).toBe(true);
});
it("serves a Class B upload attachment-only as octet-stream", async () => {
await seedVaultUser("vault-classb");
const { post, getOriginal } = await routes();
const res = await post(
uploadRequest(Buffer.from("Blutdruck 120/80 morgens\n"), "werte.csv"),
);
expect(res.status).toBe(201);
const { data } = await res.json();
expect(data.mimeType).toBe("text/csv");
expect(data.servingClass).toBe("attachment");
const orig = await getOriginal(
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
ctx(data.id),
);
expect(orig.status).toBe(200);
expect(orig.headers.get("Content-Type")).toBe("application/octet-stream");
expect(
(orig.headers.get("Content-Disposition") ?? "").startsWith("attachment;"),
).toBe(true);
expect(orig.headers.get("Content-Security-Policy")).toBeNull();
});
});
describe("document vault — policy error contract", () => {
it("415s an unsupported type with meta.reason", async () => {
await seedVaultUser("vault-415");
const { post } = await routes();
const res = await post(
uploadRequest(Buffer.from([0x01, 0x02, 0x03, 0x04]), "blob.bin"),
);
expect(res.status).toBe(415);
const body = await res.json();
expect(body.meta.reason).toBe("unsupportedType");
});
it("413s over the admin-set per-file cap with the limit in meta", async () => {
await seedVaultUser("vault-cap");
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
update: { documentMaxFileBytes: 20 },
create: { id: "singleton", documentMaxFileBytes: 20 },
});
const { post } = await routes();
const res = await post(uploadRequest(PNG_1X1, "big.png"));
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("fileTooLarge");
expect(body.meta.maxFileBytes).toBe(20);
expect(await getPrismaClient().inboundDocument.count()).toBe(0);
});
it("413s past the quota — and tombstoned rows still count against it", async () => {
const user = await seedVaultUser("vault-quota");
// Quota fits exactly one PNG (70 B) but not two.
await getPrismaClient().user.update({
where: { id: user.id },
data: { documentQuotaBytes: BigInt(PNG_1X1.byteLength + 10) },
});
const { post, del } = await routes();
const first = await post(uploadRequest(PNG_1X1, "one.png"));
expect(first.status).toBe(201);
const firstId = (await first.json()).data.id as string;
// Soft-delete the first upload: the tombstone KEEPS holding quota.
const delRes = await del(
new Request(`http://localhost/api/documents/inbound/${firstId}`, {
method: "DELETE",
}),
ctx(firstId),
);
expect(delRes.status).toBe(200);
const second = await post(uploadRequest(PDF_SMALL, "two.pdf"));
expect(second.status).toBe(413);
const body = await second.json();
expect(body.meta.reason).toBe("quotaExceeded");
expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength);
expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10);
});
});
describe("document vault — dedupe + idempotency", () => {
it("returns the existing live row + meta.duplicate on a same-bytes re-upload", async () => {
await seedVaultUser("vault-dedupe");
const { post } = await routes();
const first = await post(uploadRequest(PNG_1X1, "scan.png"));
expect(first.status).toBe(201);
const firstId = (await first.json()).data.id as string;
const second = await post(uploadRequest(PNG_1X1, "scan-copy.png"));
expect(second.status).toBe(200);
const body = await second.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe(firstId);
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
});
it("dedupes per user, not globally", async () => {
await seedVaultUser("vault-user-a");
const { post } = await routes();
expect((await post(uploadRequest(PNG_1X1, "a.png"))).status).toBe(201);
cookieJar.clear();
await seedVaultUser("vault-user-b");
expect((await post(uploadRequest(PNG_1X1, "b.png"))).status).toBe(201);
expect(await getPrismaClient().inboundDocument.count()).toBe(2);
});
it("replays an Idempotency-Key without creating a second row", async () => {
await seedVaultUser("vault-idem");
const { post } = await routes();
const key = "vault-upload-abc123";
const first = await post(
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
);
expect(first.status).toBe(201);
const firstBody = await first.json();
const replay = await post(
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
);
expect(replay.status).toBe(201);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
const replayBody = await replay.json();
expect(replayBody.data.id).toBe(firstBody.data.id);
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
});
});
describe("document vault — links + list filters", () => {
it("pre-links at upload, replace-sets via PATCH, filters by kind/episode/year", async () => {
const user = await seedVaultUser("vault-links");
const prisma = getPrismaClient();
const knie = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knie",
type: "OTHER",
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
},
});
const ruecken = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Rücken",
type: "OTHER",
onsetAt: new Date("2025-01-01T00:00:00.000Z"),
},
});
const { post, get, patch } = await routes();
const created = await post(
uploadRequest(PDF_SMALL, "mrt-knie.pdf", {
title: "MRT Knie",
kind: "IMAGING",
documentDate: "2025-10-04",
episodeIds: [knie.id],
}),
);
expect(created.status).toBe(201);
const doc = (await created.json()).data;
expect(doc.conditionLinks).toEqual([{ episodeId: knie.id, name: "Knie" }]);
// Second (unlinked, different year) document for filter contrast.
const other = await post(
uploadRequest(PNG_1X1, "impfpass.png", {
kind: "VACCINATION",
documentDate: "2024-03-01",
}),
);
expect(other.status).toBe(201);
// kind[] facet (OR inside), episode filter, year filter — AND across.
const filtered = await get(
new Request(
`http://localhost/api/documents/inbound?kind=IMAGING,LAB_RESULT&episodeId=${knie.id}&year=2025`,
),
);
expect(filtered.status).toBe(200);
const page = (await filtered.json()).data;
expect(page.documents).toHaveLength(1);
expect(page.documents[0].title).toBe("MRT Knie");
// The list DTO never carries blob material.
expect("contentEncrypted" in page.documents[0]).toBe(false);
// Year filter alone excludes the 2025 document.
const y2024 = await get(
new Request("http://localhost/api/documents/inbound?year=2024"),
);
expect((await y2024.json()).data.documents).toHaveLength(1);
expect(
(
await (
await get(new Request("http://localhost/api/documents/inbound"))
).json()
).data.documents,
).toHaveLength(2);
// PATCH replace-set: Knie → Rücken (relink), then [] (unlink-all).
const relinked = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [ruecken.id],
}),
ctx(doc.id),
);
expect(relinked.status).toBe(200);
expect((await relinked.json()).data.conditionLinks).toEqual([
{ episodeId: ruecken.id, name: "Rücken" },
]);
const unlinked = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [],
}),
ctx(doc.id),
);
expect((await unlinked.json()).data.conditionLinks).toEqual([]);
// Foreign episode id → 404-shaped refusal.
cookieJar.clear();
const stranger = await seedVaultUser("vault-links-stranger");
void stranger;
const foreign = await patch(
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
episodeIds: [knie.id],
}),
ctx(doc.id),
);
expect(foreign.status).toBe(404);
});
});
describe("document vault — delete / restore / purge", () => {
it("restore clears the tombstone; purge-then-restore 409s; duplicate restore 409s", async () => {
await seedVaultUser("vault-restore");
const prisma = getPrismaClient();
const { post, del, postRestore } = await routes();
const created = await post(uploadRequest(PNG_1X1, "scan.png"));
const docId = (await created.json()).data.id as string;
await del(
new Request(`http://localhost/api/documents/inbound/${docId}`, {
method: "DELETE",
}),
ctx(docId),
);
expect(
(await prisma.inboundDocument.findFirstOrThrow()).deletedAt,
).not.toBeNull();
// Restore inside the grace window succeeds and the row is live again.
const restored = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(restored.status).toBe(200);
const row = await prisma.inboundDocument.findFirstOrThrow();
expect(row.deletedAt).toBeNull();
expect(row.status).toBe("STORED");
// Tombstone again, upload the same bytes fresh (allowed — the partial
// index only guards LIVE rows), then the tombstone's restore conflicts.
await del(
new Request(`http://localhost/api/documents/inbound/${docId}`, {
method: "DELETE",
}),
ctx(docId),
);
const reupload = await post(uploadRequest(PNG_1X1, "scan-again.png"));
expect(reupload.status).toBe(201);
const conflicted = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(conflicted.status).toBe(409);
expect((await conflicted.json()).meta.reason).toBe("duplicateExists");
// Hard-purge the tombstone → restore now answers 409 (undo window over).
await prisma.inboundDocument.delete({ where: { id: docId } });
const gone = await postRestore(
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
method: "POST",
}),
ctx(docId),
);
expect(gone.status).toBe(409);
expect((await gone.json()).meta.reason).toBe("purged");
});
it("the purge job hard-deletes only past-grace tombstones", async () => {
const user = await seedVaultUser("vault-purge");
const prisma = getPrismaClient();
const { post, del } = await routes();
const { purgeExpiredDocumentTombstones } =
await import("@/lib/jobs/document-purge");
const a = (await (await post(uploadRequest(PNG_1X1, "old.png"))).json())
.data.id as string;
const b = (await (await post(uploadRequest(PDF_SMALL, "fresh.pdf"))).json())
.data.id as string;
const c = (
await (
await post(uploadRequest(Buffer.from("text file\n"), "live.txt"))
).json()
).data.id as string;
// a: tombstoned 31 days ago (past grace). b: tombstoned now. c: live.
for (const id of [a, b]) {
await del(
new Request(`http://localhost/api/documents/inbound/${id}`, {
method: "DELETE",
}),
ctx(id),
);
}
await prisma.inboundDocument.update({
where: { id: a },
data: { deletedAt: new Date(Date.now() - 31 * 86_400_000) },
});
const purged = await purgeExpiredDocumentTombstones(prisma);
expect(purged).toBe(1);
const remaining = await prisma.inboundDocument.findMany({
where: { userId: user.id },
select: { id: true },
orderBy: { id: "asc" },
});
expect(remaining.map((r) => r.id).sort()).toEqual([b, c].sort());
});
});
describe("document vault — bulk + usage", () => {
it("bulk applies per-id with partial-failure results", async () => {
const user = await seedVaultUser("vault-bulk");
const prisma = getPrismaClient();
const episode = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: "Knie",
type: "OTHER",
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
},
});
const { post, postBulk } = await routes();
const id1 = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json())
.data.id as string;
const id2 = (await (await post(uploadRequest(PDF_SMALL, "b.pdf"))).json())
.data.id as string;
// setKind over one real + one unknown id → per-id ok/notFound.
const setKind = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, "does-not-exist"],
action: "setKind",
kind: "LAB_RESULT",
}),
);
expect(setKind.status).toBe(200);
const { results } = (await setKind.json()).data;
expect(results).toEqual([
{ id: id1, ok: true, error: null },
{ id: "does-not-exist", ok: false, error: "notFound" },
]);
expect(
(await prisma.inboundDocument.findUniqueOrThrow({ where: { id: id1 } }))
.kind,
).toBe("LAB_RESULT");
// linkEpisode + delete + restore round-trip through bulk.
const link = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "linkEpisode",
episodeId: episode.id,
}),
);
expect(
(await link.json()).data.results.every((r: { ok: boolean }) => r.ok),
).toBe(true);
expect(await prisma.documentConditionLink.count()).toBe(2);
const bulkDelete = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "delete",
}),
);
expect(bulkDelete.status).toBe(200);
expect(
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
).toBe(0);
const bulkRestore = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: [id1, id2],
action: "restore",
}),
);
expect(bulkRestore.status).toBe(200);
expect(
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
).toBe(2);
// Cap: 101 ids → 422 (schema bound).
const over = await postBulk(
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
ids: Array.from({ length: 101 }, (_, i) => `id-${i}`),
action: "delete",
}),
);
expect(over.status).toBe(422);
});
it("usage reflects the override + tombstone-inclusive accounting", async () => {
const user = await seedVaultUser("vault-usage");
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: user.id },
data: { documentQuotaBytes: BigInt(123_456_789) },
});
const { post, del, getUsage } = await routes();
const id = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()).data
.id as string;
await del(
new Request(`http://localhost/api/documents/inbound/${id}`, {
method: "DELETE",
}),
ctx(id),
);
const res = await getUsage(
new Request("http://localhost/api/documents/inbound/usage"),
);
expect(res.status).toBe(200);
const { data } = await res.json();
// Tombstoned bytes still count until the purge reclaims them.
expect(data.usedBytes).toBe(PNG_1X1.byteLength);
expect(data.quotaBytes).toBe(123_456_789);
expect(data.maxFileBytes).toBe(26_214_400);
expect(data.acceptedExtensions).toContain(".pdf");
expect(data.acceptedExtensions).not.toContain(".heic");
});
});