diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts index 93adec25a..b22022580 100644 --- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -49,6 +49,9 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -70,6 +73,7 @@ import { import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; import { encryptDocumentToBytes } from "@/lib/documents/store"; +import { isModuleEnabled } from "@/lib/modules/gate"; const TOKEN = "hls_000000000000000000000000000000000000000000000000"; const OWNER = "owner-1"; @@ -164,6 +168,17 @@ beforeEach(() => { }); describe("GET /c/[token]/d/[id] — share document serve", () => { + it("answers 404 once the owner switched the documents module off", async () => { + seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(404); + expect(isModuleEnabled).toHaveBeenCalledWith(OWNER, "inboundDocuments"); + expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled(); + }); + it("serves a PDF inline through the token, confined to the frozen set", async () => { seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); diff --git a/src/app/c/[token]/d/[id]/route.ts b/src/app/c/[token]/d/[id]/route.ts index 54d050d81..7e16d6482 100644 --- a/src/app/c/[token]/d/[id]/route.ts +++ b/src/app/c/[token]/d/[id]/route.ts @@ -52,6 +52,7 @@ import { prisma } from "@/lib/db"; import { decryptDocumentContent } from "@/lib/documents/store"; import { servingClassFor } from "@/lib/documents/upload-policy"; import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; type RouteParams = { params: Promise<{ token: string; id: string }> }; @@ -169,6 +170,11 @@ export const GET = apiHandler( // access counters are bumped fire-and-forget. const context = await resolveShareToken(token); if (!context) return notFound(); + // The owner switched the documents module off: no document leaves, the + // same answer as an unknown one. + if (!(await isModuleEnabled(context.ownerUserId, "inboundDocuments"))) { + return notFound(); + } // 5. Confine to the frozen set: the document must be a member of THIS // link, owned by the link's owner, and live. The composite lookup ties diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 7a97cee91..d4bb99991 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -113,6 +113,25 @@ describe("loadShareViewData — KVNR default OFF", () => { expect(patient).not.toHaveProperty("kvnr"); }); + it("lists no document once the owner switched the documents module off", async () => { + moduleMap.mockResolvedValueOnce(modulesWithout("inboundDocuments")); + findDocs.mockResolvedValue([ + { + document: { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: null, + byteSize: 1, + mimeType: "application/pdf", + }, + }, + ]); + const { documents } = await loadShareViewData(ctx()); + expect(documents).toEqual([]); + expect(findDocs).not.toHaveBeenCalled(); + }); + it("lists the frozen document set as metadata only (never bytes)", async () => { findDocs.mockResolvedValue([ { diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 69d899033..b8d15d972 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -142,7 +142,7 @@ export async function loadShareViewData( // (report `null`, aggregator never called), so a disabled module collapses // the link to exactly the documents the owner attached. That is fail-closed // for the health record while keeping a public link from 500-ing. The - // documents themselves are a separate module and keep their own gate. + // documents themselves are a separate module with their own gate below. // // The whole map is resolved rather than the one key, because the per-leaf // verdicts below come off the same read. It is NOT handed to the aggregator: @@ -168,7 +168,11 @@ export async function loadShareViewData( documentOnly ? Promise.resolve(null) : collectDoctorReportData(context.ownerUserId, range, selection), - loadShareDocuments(context), + // With the documents module off, the link lists no document, and the + // serve route (`/c/[token]/d/[id]`) answers 404 for each of them. + moduleMap.inboundDocuments === false + ? Promise.resolve([]) + : loadShareDocuments(context), ]); return { report, selection, unavailableLeaves, documents, documentOnly };