mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(share): stop serving shared documents once the documents module is off
A share link gated its report on the doctorReport module but listed and served its attached documents unconditionally, although the comment beside the gate said the documents kept a gate of their own. An owner who switched the documents module off still had every attached file decrypted for anyone holding the link and its passphrase. The share page lists no document while the module is off, and the document route answers 404, the same as for an unknown document, before it looks the membership up.
This commit is contained in:
1 parent
d1fcbca63b
commit
989381b3f6
4 files changed
+46
-2
No files matched your search
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user