mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded documentIds set; validate each id as the caller's own live document and create the membership rows in the same transaction (frozen write-once). Summary DTOs gain documentCount (never ids, never bytes). Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved (never requireAuth), passphrase-gated via the token-scoped unlock cookie, revocation/expiry re-checked before any decrypt, confined to the link's frozen document set (foreign/deleted id → flat 404), fail-closed decrypt, identical serving-class posture as the owner route (inline Class A / attachment Class B, nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate limit. Carve the serve path out of the proxy's blanket DENY posture with the same document CSP as the owner route (default-src 'none'; frame-ancestors 'self'), anchored to exactly two dynamic segments so no lookalike over-matches.
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* v1.11.0 (Epic C, C4) — owner clinician-share-link lifecycle.
|
|
*
|
|
* Asserts the security-load-bearing properties: create returns the raw token
|
|
* exactly once and persists ONLY its hash; the expiry cap is enforced; revoke
|
|
* flips `revokedAt`; a cross-user revoke is sealed as 404.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
clinicianShareLink: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
inboundDocument: {
|
|
findMany: vi.fn(),
|
|
},
|
|
auditLog: { create: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
|
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
|
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
|
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
|
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 { POST, GET } from "../route";
|
|
import { DELETE } from "../[id]/route";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
|
|
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 },
|
|
};
|
|
|
|
function postReq(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/share-links", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function validBody(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
label: "Cardiology clinic",
|
|
rangeStart: "2026-01-01T00:00:00Z",
|
|
rangeEnd: null,
|
|
resourceTypes: ["Observation"],
|
|
allowFhirApi: true,
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function storedRow(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "link-1",
|
|
label: "Cardiology clinic",
|
|
rangeStart: new Date("2026-01-01T00:00:00Z"),
|
|
rangeEnd: null,
|
|
resourceTypes: ["Observation"],
|
|
allowFhirApi: true,
|
|
passphraseHash: "hash(STORED)",
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
revokedAt: null,
|
|
lastAccessAt: null,
|
|
accessCount: 0,
|
|
_count: { documents: 0 },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(checkRateLimit).mockResolvedValue({
|
|
allowed: true,
|
|
count: 1,
|
|
resetAt: new Date(),
|
|
} as never);
|
|
});
|
|
|
|
describe("POST /api/share-links — create", () => {
|
|
it("returns the raw token once and persists ONLY its hash", async () => {
|
|
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
|
storedRow() as never,
|
|
);
|
|
|
|
const res = await POST(postReq(validBody()));
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as { data: { token: string; id: string } };
|
|
|
|
// Raw token returned, prefixed, and 48 hex chars (192-bit).
|
|
expect(body.data.token).toMatch(/^hls_[0-9a-f]{48}$/);
|
|
|
|
// The persisted data carries the HMAC hash, never the raw token.
|
|
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
|
.calls[0][0];
|
|
expect(createArg.data.tokenHash).toBe(`hash(${body.data.token})`);
|
|
// No column stores the raw token verbatim — only the (mock) hash wraps it.
|
|
const { tokenHash, ...rest } = createArg.data;
|
|
expect(tokenHash).not.toBe(body.data.token);
|
|
expect(JSON.stringify(rest)).not.toContain(body.data.token);
|
|
// userId is narrowed from the session, never the body.
|
|
expect(createArg.data.userId).toBe("user-1");
|
|
});
|
|
|
|
it("returns the raw passphrase once and persists ONLY its hash", async () => {
|
|
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
|
storedRow({ passphraseHash: "hash(STORED)" }) as never,
|
|
);
|
|
|
|
const res = await POST(postReq(validBody()));
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
passphrase: string;
|
|
token: string;
|
|
shareUrl: string;
|
|
qrUrl: string;
|
|
protected: boolean;
|
|
};
|
|
};
|
|
|
|
// The raw passphrase is returned exactly once in the grouped form.
|
|
expect(body.data.passphrase).toMatch(
|
|
/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/,
|
|
);
|
|
|
|
// Stored as a hash (the mock wraps the NORMALISED bare form), never raw.
|
|
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
|
.calls[0][0];
|
|
const bare = body.data.passphrase.replace(/-/g, "");
|
|
expect(createArg.data.passphraseHash).toBe(`hash(${bare})`);
|
|
expect(createArg.data.passphraseHash).not.toContain(body.data.passphrase);
|
|
|
|
// The QR payload carries the passphrase ONLY in the URL fragment (`#k=`),
|
|
// never the path or query — and the bare share URL never carries it.
|
|
expect(body.data.qrUrl).toContain(`#k=${body.data.passphrase}`);
|
|
expect(body.data.shareUrl).not.toContain(body.data.passphrase);
|
|
expect(body.data.shareUrl).toContain(`/c/${body.data.token}`);
|
|
// Summary reports the link as passphrase-protected.
|
|
expect(body.data.protected).toBe(true);
|
|
});
|
|
|
|
it("rejects an expiry beyond the cap (422)", async () => {
|
|
const tooFar = new Date(
|
|
Date.now() + 200 * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
const res = await POST(postReq(validBody({ expiresAt: tooFar })));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a past expiry (422)", async () => {
|
|
const past = new Date(Date.now() - 1000).toISOString();
|
|
const res = await POST(postReq(validBody({ expiresAt: past })));
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("rejects an unknown key via strict schema (422)", async () => {
|
|
const res = await POST(postReq(validBody({ userId: "attacker" })));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("attaches own live documents in the create transaction (frozen write-once)", async () => {
|
|
// The ownership probe returns exactly the requested (deduped) ids as live.
|
|
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
|
{ id: "doc-a" },
|
|
{ id: "doc-b" },
|
|
] as never);
|
|
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
|
storedRow({ _count: { documents: 2 } }) as never,
|
|
);
|
|
|
|
const res = await POST(
|
|
postReq(validBody({ documentIds: ["doc-a", "doc-b", "doc-a"] })),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as { data: { documentCount: number } };
|
|
expect(body.data.documentCount).toBe(2);
|
|
|
|
// Ownership was checked against the caller's own LIVE documents.
|
|
const probeArg = vi.mocked(prisma.inboundDocument.findMany).mock
|
|
.calls[0][0];
|
|
expect(probeArg!.where).toEqual({
|
|
id: { in: ["doc-a", "doc-b"] }, // deduped
|
|
userId: "user-1",
|
|
deletedAt: null,
|
|
});
|
|
|
|
// Membership rows are created NESTED in the same create call (one txn),
|
|
// deduped, never widened afterwards.
|
|
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
|
.calls[0][0];
|
|
expect(createArg.data.documents).toEqual({
|
|
create: [{ documentId: "doc-a" }, { documentId: "doc-b" }],
|
|
});
|
|
});
|
|
|
|
it("rejects a foreign / deleted document id at create (422, no link minted)", async () => {
|
|
// Only one of the two requested ids resolves as the caller's own live doc.
|
|
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
|
{ id: "doc-a" },
|
|
] as never);
|
|
|
|
const res = await POST(
|
|
postReq(validBody({ documentIds: ["doc-a", "not-mine"] })),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
// No share is ever minted with a document the caller does not own (B5).
|
|
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects more than the document cap via strict schema (422)", async () => {
|
|
const tooMany = Array.from({ length: 51 }, (_, i) => `doc-${i}`);
|
|
const res = await POST(postReq(validBody({ documentIds: tooMany })));
|
|
expect(res.status).toBe(422);
|
|
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
|
|
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows a documents-only share (empty report sections)", 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,
|
|
);
|
|
const res = await POST(
|
|
postReq(
|
|
validBody({
|
|
resourceTypes: [],
|
|
allowFhirApi: false,
|
|
documentIds: ["doc-a"],
|
|
}),
|
|
),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as { data: { documentCount: number } };
|
|
expect(body.data.documentCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/share-links — list", () => {
|
|
it("never returns a raw token", async () => {
|
|
vi.mocked(prisma.clinicianShareLink.findMany).mockResolvedValue([
|
|
storedRow(),
|
|
] as never);
|
|
const res = await GET();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { shareLinks: Array<Record<string, unknown>> };
|
|
};
|
|
expect(body.data.shareLinks).toHaveLength(1);
|
|
expect(JSON.stringify(body.data)).not.toContain("hls_");
|
|
// Scoped to the caller.
|
|
const arg = vi.mocked(prisma.clinicianShareLink.findMany).mock.calls[0][0];
|
|
expect(arg!.where).toEqual({ userId: "user-1" });
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/share-links/[id] — revoke", () => {
|
|
function delReq(): NextRequest {
|
|
return new NextRequest("http://localhost/api/share-links/link-1", {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
it("revokes an own link (sets revokedAt, pins userId)", async () => {
|
|
vi.mocked(prisma.clinicianShareLink.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
const res = await DELETE(delReq(), {
|
|
params: Promise.resolve({ id: "link-1" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const arg = vi.mocked(prisma.clinicianShareLink.updateMany).mock
|
|
.calls[0][0];
|
|
expect(arg.where).toEqual({
|
|
id: "link-1",
|
|
userId: "user-1",
|
|
revokedAt: null,
|
|
});
|
|
expect(arg.data.revokedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("seals a cross-user / unknown id as 404", async () => {
|
|
vi.mocked(prisma.clinicianShareLink.updateMany).mockResolvedValue({
|
|
count: 0,
|
|
} as never);
|
|
const res = await DELETE(delReq(), {
|
|
params: Promise.resolve({ id: "someone-elses" }),
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|