mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
814 lines
29 KiB
TypeScript
814 lines
29 KiB
TypeScript
import { afterEach, 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,
|
|
txDocumentFindMany,
|
|
txEpisodeFindMany,
|
|
txEncounterFindMany,
|
|
txEncounterLinkCreateMany,
|
|
} = vi.hoisted(() => ({
|
|
txQueryRaw: vi.fn(),
|
|
txCreate: vi.fn(),
|
|
txCreateMany: vi.fn(),
|
|
// The link service re-narrows BOTH ends inside the transaction before it
|
|
// writes, so the transaction double has to answer for them.
|
|
txDocumentFindMany: vi.fn(),
|
|
txEpisodeFindMany: vi.fn(),
|
|
txEncounterFindMany: vi.fn(),
|
|
txEncounterLinkCreateMany: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/db", () => {
|
|
const tx = {
|
|
$queryRaw: txQueryRaw,
|
|
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
|
|
illnessEpisode: { findMany: txEpisodeFindMany },
|
|
encounter: { findMany: txEncounterFindMany },
|
|
documentConditionLink: { createMany: txCreateMany },
|
|
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
|
|
};
|
|
return {
|
|
prisma: {
|
|
inboundDocument: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
},
|
|
documentImportKey: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
extractedFact: {
|
|
groupBy: vi.fn(),
|
|
},
|
|
documentConditionLink: {
|
|
findMany: vi.fn(),
|
|
},
|
|
encounterDocumentLink: {
|
|
findMany: vi.fn(),
|
|
},
|
|
documentContentIndex: {
|
|
findMany: vi.fn(),
|
|
},
|
|
documentThumbnail: {
|
|
findMany: vi.fn(),
|
|
},
|
|
illnessEpisode: {
|
|
findMany: vi.fn(),
|
|
},
|
|
encounter: {
|
|
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/jobs/document-summary", () => ({
|
|
enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }),
|
|
}));
|
|
vi.mock("@/lib/ai/capabilities/gate", async () =>
|
|
(
|
|
await import("@/__tests__/helpers/provider-order-mock")
|
|
).openCapabilityGateMock(),
|
|
);
|
|
vi.mock("@/lib/jobs/document-thumbnail", () => ({
|
|
enqueueDocumentThumbnail: 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() }));
|
|
// The Bearer path: the real `requireAuth` runs, and only the token lookup is
|
|
// stubbed, so the scope decision under test is the production one.
|
|
vi.mock("@/lib/auth/bearer", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/auth/bearer")>();
|
|
return { ...actual, resolveBearerToken: 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 { headers } from "next/headers";
|
|
import { resolveBearerToken, BearerAuthError } from "@/lib/auth/bearer";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
|
|
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
|
|
import { requireModuleEnabled } from "@/lib/modules/gate";
|
|
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
|
|
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
|
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
|
import {
|
|
acquireDocumentUploadSlot,
|
|
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
|
|
DOCUMENT_UPLOAD_PER_USER_CONCURRENCY,
|
|
} from "@/lib/documents/upload-policy";
|
|
|
|
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.encounterDocumentLink.findMany).mockResolvedValue(
|
|
[] as never,
|
|
);
|
|
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
|
txEncounterFindMany.mockResolvedValue([]);
|
|
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
|
|
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
|
[] as never,
|
|
);
|
|
vi.mocked(prisma.documentThumbnail.findMany).mockResolvedValue([] as never);
|
|
vi.mocked(prisma.documentContentIndex.findMany).mockResolvedValue(
|
|
[] as never,
|
|
);
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
|
|
vi.mocked(prisma.documentImportKey.findUnique).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);
|
|
txDocumentFindMany.mockResolvedValue([{ id: "doc-1" }] as never);
|
|
txEpisodeFindMany.mockResolvedValue([] 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);
|
|
txEpisodeFindMany.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" }],
|
|
// The link service is idempotent by construction, so a retried upload
|
|
// that reaches the same pair succeeds instead of hitting the unique index.
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
|
|
it("rejects before reading the body when the user's upload slots are full", async () => {
|
|
const releases = Array.from(
|
|
{ length: DOCUMENT_UPLOAD_PER_USER_CONCURRENCY },
|
|
() => acquireDocumentUploadSlot("user-1"),
|
|
);
|
|
let bodyRead = false;
|
|
const unreadBody = new ReadableStream<Uint8Array>(
|
|
{
|
|
pull(controller) {
|
|
bodyRead = true;
|
|
controller.error(new Error("body must not be read"));
|
|
},
|
|
},
|
|
{ highWaterMark: 0 },
|
|
);
|
|
const request = new Request("http://localhost/api/documents/inbound", {
|
|
method: "POST",
|
|
body: unreadBody,
|
|
duplex: "half",
|
|
} as RequestInit & { duplex: "half" });
|
|
|
|
try {
|
|
const res = await post(request);
|
|
expect(res.status).toBe(429);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
meta: { reason: "uploadBusy" },
|
|
});
|
|
expect(bodyRead).toBe(false);
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
for (const release of releases) release!();
|
|
}
|
|
});
|
|
|
|
it("rejects before reading the body when process-wide upload slots are full", async () => {
|
|
const releases = Array.from(
|
|
{ length: DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY },
|
|
(_, index) => acquireDocumentUploadSlot(`other-user-${index}`),
|
|
);
|
|
try {
|
|
const res = await post(mkUpload());
|
|
expect(res.status).toBe(429);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
meta: { reason: "uploadBusy" },
|
|
});
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
} finally {
|
|
for (const release of releases) release!();
|
|
}
|
|
});
|
|
|
|
it("releases an upload slot after an invalid multipart body", async () => {
|
|
const invalid = new Request("http://localhost/api/documents/inbound", {
|
|
method: "POST",
|
|
headers: { "content-type": "multipart/form-data; boundary=incomplete" },
|
|
body: "--incomplete\r\n",
|
|
});
|
|
const invalidResponse = await post(invalid);
|
|
expect(invalidResponse.status).toBe(400);
|
|
|
|
const held = acquireDocumentUploadSlot("user-1");
|
|
expect(held).not.toBeNull();
|
|
try {
|
|
const validResponse = await post(mkUpload());
|
|
expect(validResponse.status).toBe(201);
|
|
} finally {
|
|
held!();
|
|
}
|
|
});
|
|
|
|
it("does not make another whole-body Blob copy before multipart parsing", async () => {
|
|
const request = mkUpload();
|
|
const NativeBlob = globalThis.Blob;
|
|
let blobConstructions = 0;
|
|
class CountingBlob extends NativeBlob {
|
|
constructor(parts?: BlobPart[], options?: BlobPropertyBag) {
|
|
blobConstructions += 1;
|
|
super(parts, options);
|
|
}
|
|
}
|
|
globalThis.Blob = CountingBlob;
|
|
try {
|
|
const res = await post(request);
|
|
expect(res.status).toBe(201);
|
|
expect(blobConstructions).toBe(0);
|
|
} finally {
|
|
globalThis.Blob = NativeBlob;
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
// The link read resolves labels in a second query rather than through a
|
|
// relation join, so both halves are mocked.
|
|
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([
|
|
{ documentId: "d1", episodeId: "ep-1" },
|
|
] as never);
|
|
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
|
|
{ id: "ep-1", label: "Knie", onsetAt: new Date("2026-06-01T00:00:00Z") },
|
|
] 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);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => {
|
|
it("enqueues the background summary when document reading is open", async () => {
|
|
const res = await post(mkUpload());
|
|
expect(res.status).toBe(201);
|
|
expect(getAiCapability).toHaveBeenCalledWith("documentAi");
|
|
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
|
|
});
|
|
|
|
it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => {
|
|
vi.mocked(getAiCapability).mockResolvedValueOnce({
|
|
available: false,
|
|
reason: "operator_disabled",
|
|
onDeviceAllowed: false,
|
|
});
|
|
const res = await post(mkUpload());
|
|
// The upload is data: accepted whatever AI says.
|
|
expect(res.status).toBe(201);
|
|
expect(txCreate).toHaveBeenCalled();
|
|
// The index job still runs its provider-free text-layer path.
|
|
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
|
|
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.39.2 (#1038) — the narrow `documents:write` token, and the import fields
|
|
* any caller may send. The Bearer is resolved by the real `requireAuth`; only
|
|
* the token lookup is stubbed.
|
|
*/
|
|
describe("POST /api/documents/inbound — document import", () => {
|
|
const TOKEN = "hlk_" + "d".repeat(64);
|
|
|
|
function armToken(permissions: string[]) {
|
|
vi.mocked(getSession).mockResolvedValue(null as never);
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
|
|
} as never);
|
|
vi.mocked(resolveBearerToken).mockImplementation(async (_raw, req) => {
|
|
const admitted =
|
|
permissions.includes("*") ||
|
|
(req.kind === "scope" && permissions.includes(req.scope));
|
|
if (!admitted) {
|
|
throw new BearerAuthError(
|
|
403,
|
|
"insufficient_permissions",
|
|
"user-1",
|
|
"tok-1",
|
|
);
|
|
}
|
|
return {
|
|
user: { id: "user-1", username: "tester", role: "USER" },
|
|
tokenId: "tok-1",
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|
permissions,
|
|
} as never;
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(checkRateLimit).mockResolvedValue({
|
|
allowed: true,
|
|
remaining: 59,
|
|
resetAt: Date.now() + 3_600_000,
|
|
} as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
|
});
|
|
|
|
it("admits the scoped token and answers with a receipt, not the row", async () => {
|
|
armToken([DOCUMENTS_WRITE_SCOPE]);
|
|
txCreate.mockResolvedValue(
|
|
docRow({ title: "Befund", filename: "befund.png" }) as never,
|
|
);
|
|
|
|
const res = await post(mkUpload({ title: "Befund" }));
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
// Exactly the receipt: no title, filename, kind, links or counts.
|
|
expect(body.data).toEqual({ id: "doc-1", duplicate: false });
|
|
});
|
|
|
|
it("gives the scoped token its own bucket, keyed on the token", async () => {
|
|
armToken([DOCUMENTS_WRITE_SCOPE]);
|
|
await post(mkUpload());
|
|
const key = vi.mocked(checkRateLimit).mock.calls[0]![0];
|
|
expect(key).toBe("documents-upload:token:tok-1");
|
|
// Default 120/hour for a token; the cookie bucket stays at 60.
|
|
expect(vi.mocked(checkRateLimit).mock.calls[0]![1]).toBe(120);
|
|
});
|
|
|
|
it("keeps the cookie bucket for a session", async () => {
|
|
await post(mkUpload());
|
|
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
|
|
"documents-upload:user-1",
|
|
);
|
|
expect(vi.mocked(checkRateLimit).mock.calls[0]![1]).toBe(60);
|
|
});
|
|
|
|
it("429s the scoped token past its bucket with Retry-After", async () => {
|
|
armToken([DOCUMENTS_WRITE_SCOPE]);
|
|
vi.mocked(checkRateLimit).mockResolvedValue({
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit: 120,
|
|
resetAt: Date.now() + 90_000,
|
|
} as never);
|
|
const res = await post(mkUpload());
|
|
expect(res.status).toBe(429);
|
|
expect((await res.json()).meta.errorCode).toBe(
|
|
"documents.inbound.rateLimited",
|
|
);
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a token carrying some other narrow scope", async () => {
|
|
armToken(["measurements:write"]);
|
|
const res = await post(mkUpload());
|
|
expect(res.status).toBe(403);
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("answers a scoped content duplicate with the receipt", async () => {
|
|
armToken([DOCUMENTS_WRITE_SCOPE]);
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
|
docRow({ id: "doc-existing", title: "Private title" }) as never,
|
|
);
|
|
const res = await post(mkUpload());
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.data).toEqual({ id: "doc-existing", duplicate: true });
|
|
expect(body.meta.duplicate).toBe(true);
|
|
});
|
|
|
|
it("stores the source key on a fresh import", async () => {
|
|
armToken([DOCUMENTS_WRITE_SCOPE]);
|
|
await post(mkUpload({ sourceSystem: "PAPERLESS", sourceId: "412" }));
|
|
const arg = txCreate.mock.calls[0]![0]!;
|
|
expect(arg.data.sourceSystem).toBe("PAPERLESS");
|
|
expect(arg.data.sourceId).toBe("412");
|
|
});
|
|
|
|
it("answers a live source-key match as a duplicate without reading bytes", async () => {
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValueOnce(
|
|
docRow({
|
|
id: "doc-src",
|
|
sourceSystem: "PAPRA",
|
|
sourceId: "doc_1",
|
|
}) as never,
|
|
);
|
|
const res = await post(
|
|
mkUpload({ sourceSystem: "PAPRA", sourceId: "doc_1" }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.meta.duplicate).toBe(true);
|
|
expect(body.data.id).toBe("doc-src");
|
|
// The lookup spans tombstones: no deletedAt predicate.
|
|
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
|
|
.where!;
|
|
expect(where).toEqual({
|
|
userId: "user-1",
|
|
sourceSystem: "PAPRA",
|
|
sourceId: "doc_1",
|
|
});
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses to bring back a deleted import: 200 deleted, no new row", async () => {
|
|
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValueOnce(
|
|
docRow({
|
|
id: "doc-gone",
|
|
sourceSystem: "PAPERLESS",
|
|
sourceId: "7",
|
|
deletedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
}) as never,
|
|
);
|
|
const res = await post(
|
|
mkUpload({ sourceSystem: "PAPERLESS", sourceId: "7" }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.data).toEqual({
|
|
id: "doc-gone",
|
|
duplicate: true,
|
|
deleted: true,
|
|
});
|
|
expect(body.meta).toEqual({ duplicate: true, deleted: true });
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
expect(enqueueDocumentIndex).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("remembers a purged import through the ledger", async () => {
|
|
vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue({
|
|
id: "key-1",
|
|
} as never);
|
|
const res = await post(
|
|
mkUpload({ sourceSystem: "PAPERLESS", sourceId: "7" }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.data).toEqual({ id: null, duplicate: true, deleted: true });
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("422s a sourceId without a sourceSystem", async () => {
|
|
const res = await post(mkUpload({ sourceId: "7" }));
|
|
expect(res.status).toBe(422);
|
|
expect(txCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("422s a sourceSystem outside the closed set", async () => {
|
|
const res = await post(
|
|
mkUpload({ sourceSystem: "DROPBOX", sourceId: "7" }),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("aiRead=defer indexes locally and skips the summary", async () => {
|
|
const res = await post(mkUpload({ aiRead: "defer" }));
|
|
expect(res.status).toBe(201);
|
|
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1", {
|
|
localOnly: true,
|
|
});
|
|
expect(enqueueDocumentThumbnail).toHaveBeenCalledWith("user-1", "doc-1");
|
|
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("without aiRead the upload keeps today's AI behaviour", async () => {
|
|
await post(mkUpload());
|
|
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
|
|
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
|
|
});
|
|
});
|