mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(clinician-share): a document share exposes only the document
Sharing a single document from the vault minted a full-record share: the create form always defaulted `resourceTypes` to ["Patient","Observation"] and never signalled a documents-only intent, so the served /c/<token> view aggregated the whole doctor report — every vital, lab, medication and wellness figure — alongside the attached file. An empty section blob was indistinguishable from "defaults", so the report always resolved to the full record. Introduce an explicit documents-only share: - add a `documentOnly` flag to the create payload; when set the server forces an empty report scope (all-off sections, no FHIR resource types, FHIR API off) and requires at least one document, so a document link can never widen into a record — whatever the body sends. - gate the clinician-view aggregation on an enabled section: a share with no section on never calls the report aggregator, so no health metric leaves the database. The recipient sees only the attached document(s). - the document detail Share action opens the create form in documents-only mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the history-range selector, keep the picker/label/expiry/passphrase, and always carry the launched document into the created link. - Settings sharing keeps the full record-share form unchanged. Extract the create-payload builder as a pure function and cover the scope contract, the empty-scope data load, and the served-view metric-free guarantee with tests; refresh the share e2e and regenerate the OpenAPI contract.
This commit is contained in:
1 parent
fa42c8bc0b
commit
d4965033c2
13 files changed
+503
-92
No files matched your search
@@ -41,6 +41,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { DELETE } from "../[id]/route";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -260,6 +261,50 @@ describe("POST /api/share-links — create", () => {
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("documentOnly FORCES an empty report scope even when the body smuggles one", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
);
|
||||
|
||||
// A hostile / stale client tries to widen a document link into a record
|
||||
// share: FHIR on, resource types set, sections requested. The server must
|
||||
// ignore every one of them.
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
documentOnly: true,
|
||||
allowFhirApi: true,
|
||||
resourceTypes: ["Observation", "Patient"],
|
||||
sections: { vitals: { bp: true }, labs: true },
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
||||
.calls[0][0];
|
||||
// Empty FHIR scope, FHIR API off — no health surface at all.
|
||||
expect(createArg.data.resourceTypes).toEqual([]);
|
||||
expect(createArg.data.allowFhirApi).toBe(false);
|
||||
// Sections persist as an explicit all-OFF blob (distinct from `{}` = full
|
||||
// record defaults). The clinician view reads this as "no report".
|
||||
expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
|
||||
// The picked document still rides along.
|
||||
expect(createArg.data.documents).toEqual({
|
||||
create: [{ documentId: "doc-a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a documentOnly share with no documents (422)", async () => {
|
||||
const res = await POST(postReq(validBody({ documentOnly: true })));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/share-links — list", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import {
|
||||
generatePassphrase,
|
||||
hashPassphrase,
|
||||
@@ -134,6 +135,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
|
||||
// columns to empty here (not just at the client) so a document link can never
|
||||
// serve a single health metric, whatever the body tried to smuggle: no
|
||||
// sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
|
||||
// "full record defaults"), no FHIR resource types, FHIR API off. "Share this
|
||||
// document" means the document, not the whole record.
|
||||
const documentOnly = input.documentOnly === true;
|
||||
const sectionsJson = documentOnly
|
||||
? EMPTY_DOCTOR_REPORT_PREFS
|
||||
: (input.sections ?? {});
|
||||
const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
|
||||
const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
|
||||
annotate({ meta: { documentOnly } });
|
||||
|
||||
// Mint the 192-bit raw token, store only its HMAC hash.
|
||||
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
|
||||
const tokenHash = hashToken(rawToken);
|
||||
@@ -175,9 +190,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
label: input.label,
|
||||
rangeStart: new Date(input.rangeStart),
|
||||
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
|
||||
sectionsJson: (input.sections ?? {}) as Prisma.InputJsonValue,
|
||||
resourceTypes: input.resourceTypes ?? [],
|
||||
allowFhirApi: input.allowFhirApi ?? false,
|
||||
sectionsJson: sectionsJson as Prisma.InputJsonValue,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
|
||||
Reference in new issue
Block a user