mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(documents): view and download the original inbound document
The inbound-document store keeps the uploaded doctor letter encrypted at rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way to read the original back — the decrypt helper had been dropped as unused. - Restore decryptDocumentFromBytes in the document store, fail-closed on a bad / missing key id. - Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed query), module-gated, returns the decrypted bytes with the stored MIME type. A PDF / image is served inline so the browser can render it; other types download as an attachment. The filename is sanitised before it reaches the Content-Disposition header. The bytes are PHI — never logged, never served cross-user, and a decrypt error fails closed (500) rather than returning ciphertext. Cache-Control is private, no-store. - Surface a 'View original' action in the document review screen. - Document the route in the OpenAPI registry and regenerate the spec. - Cover the route with a test: owner-scoped success, attachment fallback, 404 for another user's document, and fail-closed on decrypt error. i18n: viewOriginal across all six locales.
This commit is contained in:
1 parent
6e204d054a
commit
08314c7c09
12 files changed
+418
-10
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25.1 (W-DOCS-IN) — `GET /api/documents/inbound/[id]/original`.
|
||||
*
|
||||
* The route decrypts and serves the raw uploaded document. Load-bearing
|
||||
* behaviour under test: it is owner-scoped (the query narrows on the session
|
||||
* userId, so another user's document is a 404), it returns the decrypted bytes
|
||||
* with the stored MIME type, and it fails closed on a decrypt error.
|
||||
*/
|
||||
|
||||
// A reversible crypto stub so encryptDocumentToBytes / decryptDocumentFromBytes
|
||||
// round-trip without real keys. `decrypt` is overridden per-test for the
|
||||
// fail-closed path.
|
||||
const decryptMock = vi.fn((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`,
|
||||
decrypt: (s: string) => decryptMock(s),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
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 { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptDocumentToBytes } from "@/lib/documents/store";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
const callGet = GET as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: Ctx,
|
||||
) => Promise<Response>;
|
||||
|
||||
function makeReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/original`),
|
||||
);
|
||||
}
|
||||
function ctx(id: string): Ctx {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
it("returns the decrypted original with the stored MIME type, owner-scoped", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("report.pdf");
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
expect(out.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
// The query is narrowed on the session userId + live rows only.
|
||||
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0]
|
||||
?.where as { id: string; userId: string; deletedAt: null };
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.id).toBe("doc-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a non-inline type (text) as an attachment", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-2",
|
||||
filename: null,
|
||||
mimeType: "text/plain",
|
||||
contentEncrypted: encryptDocumentToBytes(Buffer.from("plain text")),
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq("doc-2"), ctx("doc-2"));
|
||||
expect(res.status).toBe(200);
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
expect(disposition).toContain("attachment");
|
||||
// No filename → generated fallback keyed by id + extension.
|
||||
expect(disposition).toContain("document-doc-2.txt");
|
||||
});
|
||||
|
||||
it("404s for another user's document (the userId narrows it out)", async () => {
|
||||
// The owner-scoped where clause yields no row for a non-owner.
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq("other-doc"), ctx("other-doc"));
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-3",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("unknown key id");
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq("doc-3"), ctx("doc-3"));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.headers.get("Content-Type")).toContain("application/json");
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.25.1 (W-DOCS-IN) — view / download the original uploaded document.
|
||||
*
|
||||
* The raw doctor report / discharge letter is stored encrypted at rest
|
||||
* (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY
|
||||
* path that decrypts and serves it, so the user can re-read the source the
|
||||
* extracted facts came from. Owner-scoped + module-gated; the bytes are PHI:
|
||||
* never logged, never served cross-user, fail-closed on a decrypt error.
|
||||
*
|
||||
* The response carries the stored `mimeType`. A PDF / image is served inline
|
||||
* (`Content-Disposition: inline`) so the browser can render it in a tab; any
|
||||
* other type (e.g. the text-mode upload) is sent as an attachment. The
|
||||
* filename is sanitised before it reaches the header.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptDocumentFromBytes } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** MIME types we render inline; everything else downloads as an attachment. */
|
||||
const INLINE_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
/** Extension fallback when the stored filename is absent / unusable. */
|
||||
const MIME_EXTENSION: Record<string, string> = {
|
||||
"application/pdf": "pdf",
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"text/plain": "txt",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a header-safe ASCII filename. Strips control characters (incl. CR/LF,
|
||||
* the header-injection vector), quotes, and path separators (path traversal),
|
||||
* caps the length, and falls back to a generated name keyed by the document
|
||||
* id + MIME type when nothing usable remains.
|
||||
*/
|
||||
function safeDownloadName(
|
||||
filename: string | null,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
): string {
|
||||
const ext = MIME_EXTENSION[mimeType] ?? "bin";
|
||||
const cleaned = (filename ?? "")
|
||||
// Strip control chars (incl. CR/LF — header injection), quotes, and path
|
||||
// separators (path traversal) before the value reaches the header.
|
||||
.replace(/[\u0000-\u001f"\\\/]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
// Owner-scoped: the userId narrows the row so a cuid guess cannot reach
|
||||
// another user's document. A cookie session and a Bearer token both
|
||||
// resolve through requireAuth(); neither can cross the userId boundary.
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
contentEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = decryptDocumentFromBytes(document.contentEncrypted);
|
||||
} catch {
|
||||
// Fail closed — never fall back to the raw ciphertext. The error reason
|
||||
// (bad / missing key id) is logged by the annotation, never the bytes.
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.decryptFailed" },
|
||||
meta: { documentId: document.id },
|
||||
});
|
||||
return apiError("Could not read the stored document", 500, {
|
||||
errorCode: "documents.inbound.decryptFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const inline = INLINE_MIME_TYPES.has(document.mimeType);
|
||||
const downloadName = safeDownloadName(
|
||||
document.filename,
|
||||
document.id,
|
||||
document.mimeType,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.get" },
|
||||
meta: {
|
||||
documentId: document.id,
|
||||
mimeType: document.mimeType,
|
||||
bytes: bytes.byteLength,
|
||||
disposition: inline ? "inline" : "attachment",
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(bytes as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType,
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${downloadName}"`,
|
||||
// Private PHI — never cache in a shared / disk cache.
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user