Files
HealthLog/src/app/api/documents/inbound/__tests__/route.test.ts
T
Marc-André Bombeck 5e253313fc feat(documents): auto-index uploads for content search, provider-first with local fallback
Index a document for content search automatically the moment it is stored,
instead of requiring a manual index action. A fire-and-forget per-document
job runs an AI-first decision tree: when a vision-capable provider is
configured and consented, transcribe the stored original (rich, handles
scanned PDFs and images), budget-reserved exactly like the interactive index
route; otherwise fall back to server-side text-layer extraction via pdf-parse
(no provider, no egress, milliseconds) so content search works at a baseline
even with no AI. Budget exhaustion, a provider that cannot read the file
(a text-layer PDF on a non-Anthropic account), and provider errors all fall
through to the free local path rather than leave a document unsearchable.

Both paths write the same blind, encrypted content index (AES-256-GCM text +
opaque HMAC token tags) — nothing readable at rest. The local path adds the
'local-pdf' / 'local-ocr' source values (free String column, no migration).
Server-side OCR for scanned images and scanned PDFs is left as a documented
seam. Pin pdf-parse + pdfjs-dist into the standalone trace so the wasm text
extractor ships in the runtime image.
2026-07-08 20:50:30 +02:00

411 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Document vault: store-only upload + browsable list.
*
* Pins the vault upload contract: a file persists as a STORED row with the
* binary codec + plaintext sha256 and NO provider / consent / budget call on
* the path, the policy-layer error contract (413 fileTooLarge / quotaExceeded
* with limits in `meta`, 415 unsupportedType), the duplicate short-circuit
* (200 + `meta.duplicate`, no second row), and — hardening — that the list
* query NEVER selects the encrypted blob column.
*/
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
txCreateMany: vi.fn(),
}));
vi.mock("@/lib/db", () => {
const tx = {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate },
documentConditionLink: { createMany: txCreateMany },
};
return {
prisma: {
inboundDocument: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
documentConditionLink: {
findMany: vi.fn(),
},
documentContentIndex: {
findMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
$transaction: vi.fn(async (cb: (t: unknown) => Promise<unknown>) =>
cb(tx),
),
},
};
});
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
vi.mock("@/lib/jobs/document-index", () => ({
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 3_600_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { createHash } from "node:crypto";
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
const post = POST as unknown as (r: Request) => Promise<Response>;
const get = GET as unknown as (r: Request) => Promise<Response>;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
const PNG_BYTES = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
const PNG_SHA256 = createHash("sha256").update(PNG_BYTES).digest("hex");
function mkUpload(
fields: Record<string, string | string[]> = {},
bytes: Buffer = PNG_BYTES,
filename = "x.png",
) {
const blobBytes = new Uint8Array(bytes.byteLength);
blobBytes.set(bytes);
const formData = new FormData();
formData.append("file", new Blob([blobBytes]), 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,
});
}
function docRow(over: Record<string, unknown> = {}) {
return {
id: "doc-1",
userId: "user-1",
kind: "OTHER",
title: null,
filename: "x.png",
mimeType: "image/png",
byteSize: 70,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
contentSha256: PNG_SHA256,
contentCodec: "binary2",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.documentContentIndex.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
// No settings row / no user override → policy defaults (25 MiB / 1 GiB).
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]);
txCreate.mockResolvedValue(docRow() as never);
txCreateMany.mockResolvedValue({ count: 0 } as never);
});
describe("POST /api/documents/inbound (vault upload)", () => {
it("stores a file binary-codec encrypted with its plaintext sha256", async () => {
txCreate.mockResolvedValue(
docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never,
);
const res = await post(
mkUpload({ title: "Blood panel", kind: "LAB_RESULT" }),
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data.status).toBe("STORED");
expect(body.data.kind).toBe("LAB_RESULT");
expect(body.data.title).toBe("Blood panel");
expect(body.data.servingClass).toBe("inline");
expect(body.data.conditionLinks).toEqual([]);
// The row is created with status STORED, no provider, userId from the
// session, the ACTIVE binary codec, and the plaintext sha256.
const arg = txCreate.mock.calls[0]![0]!;
expect(arg.data.status).toBe("STORED");
expect(arg.data.userId).toBe("user-1");
expect(arg.data.kind).toBe("LAB_RESULT");
expect(arg.data.contentCodec).toBe("binary2");
expect(arg.data.contentSha256).toBe(PNG_SHA256);
expect("providerType" in arg.data).toBe(false);
// The persisted blob is ciphertext, never the plaintext bytes.
const stored = Buffer.from(arg.data.contentEncrypted as Uint8Array);
expect(stored.equals(PNG_BYTES)).toBe(false);
// The create never round-trips the blob back out.
expect(arg.omit).toEqual({ contentEncrypted: true });
// A fresh store auto-enqueues the per-document index job (owner + id).
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
});
it("rejects an unidentifiable payload with 415 + reason unsupportedType", async () => {
const res = await post(
mkUpload({}, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), "x.bin"),
);
expect(res.status).toBe(415);
const body = await res.json();
expect(body.meta.reason).toBe("unsupportedType");
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an over-cap file with 413 + the configured limit", async () => {
// Admin capped uploads below the PNG's 70 bytes (clamped ≥ 1 in the
// resolver); the file itself trips the per-file check.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 16,
documentQuotaBytes: BigInt(1_073_741_824),
} as never);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("fileTooLarge");
expect(body.meta.maxFileBytes).toBe(16);
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an upload past the quota with 413 + quota figures", async () => {
// Per-user override of 100 bytes; 60 already used; the 70-byte PNG tips it.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: BigInt(100),
} as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(60) }]);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("quotaExceeded");
expect(body.meta.quotaBytes).toBe(100);
expect(body.meta.usedBytes).toBe(60);
expect(txCreate).not.toHaveBeenCalled();
});
it("returns the existing live row with meta.duplicate on a re-upload", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
docRow({ id: "doc-existing" }) as never,
);
const res = await post(mkUpload());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe("doc-existing");
// The duplicate lookup is scoped to the caller's LIVE rows by sha256.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
.where!;
expect(where.userId).toBe("user-1");
expect(where.contentSha256).toBe(PNG_SHA256);
expect(where.deletedAt).toBeNull();
expect(txCreate).not.toHaveBeenCalled();
// A duplicate short-circuits before the store — no index job is enqueued.
expect(enqueueDocumentIndex).not.toHaveBeenCalled();
});
it("pre-links the caller's episodes and refuses a foreign episode id", async () => {
// Owned episode → link rows land in the same transaction.
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
] as never);
let res = await post(mkUpload({ episodeIds: ["ep-1"] }));
expect(res.status).toBe(201);
expect(txCreateMany).toHaveBeenCalledWith({
data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }],
});
// Foreign/unknown episode id → 404-shaped refusal, nothing persisted.
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
res = await post(mkUpload({ episodeIds: ["ep-foreign"] }));
expect(res.status).toBe(404);
expect(txCreate).not.toHaveBeenCalled();
});
});
describe("GET /api/documents/inbound (list)", () => {
it("never selects the encrypted blob column (omit hardening)", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound"),
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
expect(arg.omit).toEqual({ contentEncrypted: true });
expect(arg.select).toBeUndefined();
expect(arg.include).toBeUndefined();
});
it("applies q + multi-kind + episode filters and returns grouped counts", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
docRow({ id: "d1" }),
docRow({ id: "d2" }),
] as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([
{ documentId: "d2", status: "PENDING", _count: { _all: 1 } },
{ documentId: "d2", status: "APPROVED", _count: { _all: 1 } },
{ documentId: "d2", status: "REJECTED", _count: { _all: 3 } },
] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([
{
documentId: "d1",
episodeId: "ep-1",
episode: { label: "Knie" },
},
] as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&kind=IMAGING&episodeId=ep-1&sort=createdAt&order=desc&limit=10",
),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.documents).toHaveLength(2);
expect(body.data.nextCursor).toBeNull();
// d1 carries its condition link; d2 has 1 pending + 1 approved +
// 3 rejected → factCount 2 (rejected excluded), pendingCount 1.
const [d1, d2] = body.data.documents;
expect(d1.conditionLinks).toEqual([{ episodeId: "ep-1", name: "Knie" }]);
expect(d1.factCount).toBe(0);
expect(d2.factCount).toBe(2);
expect(d2.pendingCount).toBe(1);
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
const where = arg.where!;
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(where.kind).toEqual({ in: ["LAB_RESULT", "IMAGING"] });
expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } });
// Title/filename substring branches, unioned with the blind content-token
// overlap (the third branch carries the HMAC'd query tokens for "panel").
expect(where.OR).toEqual([
{ title: { contains: "panel", mode: "insensitive" } },
{ filename: { contains: "panel", mode: "insensitive" } },
{
contentIndex: { is: { searchTokens: { hasSome: expect.any(Array) } } },
},
]);
// limit+1 fetched for the has-more probe.
expect(arg.take).toBe(11);
});
it("maps the year filter to a UTC calendar-year documentDate range", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?year=2025"),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
expect(where.documentDate).toEqual({
gte: new Date(Date.UTC(2025, 0, 1)),
lt: new Date(Date.UTC(2026, 0, 1)),
});
});
it("emits nextCursor when a full page+1 is returned", async () => {
const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` }));
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?limit=2"),
);
const body = await res.json();
expect(body.data.documents).toHaveLength(2);
expect(body.data.nextCursor).toBe("d1");
});
it("422s on a bad sort value", async () => {
const res = await get(
new Request("http://localhost/api/documents/inbound?sort=nope"),
);
expect(res.status).toBe(422);
});
});