mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated
# Conflicts: # prisma/schema.prisma
This commit is contained in:
commit
9ff39b5846
16 files changed
+1490
-41
No files matched your search
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
inboundDocument: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -81,6 +84,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
revokedAt: null,
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
_count: { documents: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -178,6 +182,84 @@ describe("POST /api/share-links — create", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -54,6 +54,27 @@ function resolveOrigin(request: NextRequest): string {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row selection every owner-facing projection reads. `_count.documents`
|
||||
* surfaces the size of the frozen document set without ever loading the ids
|
||||
* (let alone the blobs) — the metadata-only discipline (P3-D5).
|
||||
*/
|
||||
const shareLinkSummarySelect = {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
@@ -68,6 +89,7 @@ function toSummary(row: {
|
||||
revokedAt: Date | null;
|
||||
lastAccessAt: Date | null;
|
||||
accessCount: number;
|
||||
_count: { documents: number };
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -79,6 +101,9 @@ function toSummary(row: {
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
// v1.28 — how many documents this share carries (never the ids, never the
|
||||
// bytes; the serve route is the only decrypt path).
|
||||
documentCount: row._count.documents,
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
|
||||
@@ -120,6 +145,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rawPassphrase = generatePassphrase();
|
||||
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
|
||||
|
||||
// v1.28 — resolve the frozen document set. The ids are deduped (a client
|
||||
// could repeat one, which the unique membership index would otherwise
|
||||
// reject) and then validated as the caller's OWN live documents. A single
|
||||
// foreign / deleted / unknown id seals the whole create as 422 — no share is
|
||||
// ever minted with a document the caller does not own (B5). The rows are
|
||||
// created in the SAME transaction as the link (nested create), so the
|
||||
// membership is frozen write-once atomically with the scope columns.
|
||||
const documentIds = Array.from(new Set(input.documentIds ?? []));
|
||||
if (documentIds.length > 0) {
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
where: { id: { in: documentIds }, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (owned.length !== documentIds.length) {
|
||||
return apiError("One or more documents are not available to share", 422, {
|
||||
errorCode: "share-link.documents.invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field build — no mass assignment. Scope columns are written
|
||||
// exactly once here and never updated.
|
||||
const created = await prisma.clinicianShareLink.create({
|
||||
@@ -134,21 +179,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
resourceTypes: input.resourceTypes ?? [],
|
||||
allowFhirApi: input.allowFhirApi ?? false,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
? { create: documentIds.map((documentId) => ({ documentId })) }
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
await auditLog("share-link.create", {
|
||||
@@ -158,6 +194,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
shareLinkId: created.id,
|
||||
resourceTypes: created.resourceTypes,
|
||||
allowFhirApi: created.allowFhirApi,
|
||||
documentCount: created._count.documents,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -188,20 +225,7 @@ export const GET = apiHandler(async () => {
|
||||
const rows = await prisma.clinicianShareLink.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28 (document vault, Phase 3) — public share-scoped document serve route
|
||||
* `GET /c/[token]/d/[id]`.
|
||||
*
|
||||
* Load-bearing behaviour under test: it resolves ONLY via the share token
|
||||
* (never a session), confines serving to the link's frozen document set (a
|
||||
* foreign / unknown / soft-deleted id → flat 404), re-enforces the passphrase
|
||||
* gate and revocation/expiry BEFORE any decrypt, applies the identical
|
||||
* serving-class posture as the owner route (inline Class A / attachment Class
|
||||
* B + octet-stream + nosniff + no-store, no route-level CSP), strips EXIF from
|
||||
* raster images on egress, and fails closed on a decrypt error.
|
||||
*/
|
||||
|
||||
// Reversible crypto stub (mirrors the owner /original route test) so the
|
||||
// document codec round-trips without real keys.
|
||||
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: {
|
||||
clinicianShareLinkDocument: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
|
||||
resolveShareGateState: vi.fn(),
|
||||
resolveShareToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
|
||||
unlockCookieName: (h: string) => `hls_unlock_${h.slice(0, 16)}`,
|
||||
verifyUnlockValue: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 299, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
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 { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveShareGateState,
|
||||
resolveShareToken,
|
||||
} from "@/lib/clinician-share/resolve-share-token";
|
||||
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { encryptDocumentToBytes } from "@/lib/documents/store";
|
||||
|
||||
const TOKEN = "hls_000000000000000000000000000000000000000000000000";
|
||||
const OWNER = "owner-1";
|
||||
const SHARE_LINK_ID = "link-1";
|
||||
|
||||
type Ctx = { params: Promise<{ token: string; 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/c/${TOKEN}/d/${id}`));
|
||||
}
|
||||
function ctx(id: string): Ctx {
|
||||
return { params: Promise.resolve({ token: TOKEN, id }) };
|
||||
}
|
||||
|
||||
/** Seed a membership row whose document decrypts to `bytes` of `mimeType`. */
|
||||
function seedMembership(opts: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
filename?: string | null;
|
||||
mimeType: string;
|
||||
bytes: Buffer;
|
||||
deletedAt?: Date | null;
|
||||
}) {
|
||||
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue({
|
||||
document: {
|
||||
id: opts.id ?? "doc-1",
|
||||
userId: opts.userId ?? OWNER,
|
||||
filename: opts.filename ?? "report.pdf",
|
||||
mimeType: opts.mimeType,
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(opts.bytes),
|
||||
deletedAt: opts.deletedAt ?? null,
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
|
||||
|
||||
// A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment.
|
||||
function jpegWithGps(): Buffer {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0"),
|
||||
Buffer.from("GPSLatitude=48.137"),
|
||||
]);
|
||||
const app1 = Buffer.concat([
|
||||
Buffer.from([0xff, 0xe1]),
|
||||
(() => {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(payload.length + 2);
|
||||
return b;
|
||||
})(),
|
||||
payload,
|
||||
]);
|
||||
const sos = Buffer.concat([
|
||||
Buffer.from([0xff, 0xda]),
|
||||
Buffer.from([0x00, 0x08]),
|
||||
Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]),
|
||||
Buffer.from([0xaa, 0xbb]),
|
||||
]);
|
||||
return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 299,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Default: a live, unprotected link resolving to the owner scope.
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue({
|
||||
tokenHash: "hash-1",
|
||||
passphraseHash: null,
|
||||
} as never);
|
||||
vi.mocked(resolveShareToken).mockResolvedValue({
|
||||
shareLinkId: SHARE_LINK_ID,
|
||||
ownerUserId: OWNER,
|
||||
label: "Clinic",
|
||||
rangeStart: new Date(),
|
||||
rangeEnd: null,
|
||||
sectionsJson: {},
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /c/[token]/d/[id] — share document serve", () => {
|
||||
it("serves a PDF inline through the token, confined to the frozen set", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
|
||||
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("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
// The response CSP is proxy-owned; the route sets none.
|
||||
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
expect(out.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
// The membership lookup ties the id to THIS link (token-confined scope).
|
||||
const arg = vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mock
|
||||
.calls[0][0];
|
||||
expect(arg!.where).toEqual({
|
||||
shareLinkId_documentId: {
|
||||
shareLinkId: SHARE_LINK_ID,
|
||||
documentId: "doc-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("strips EXIF/GPS from a shared JPEG on egress", async () => {
|
||||
const jpeg = jpegWithGps();
|
||||
seedMembership({ mimeType: "image/jpeg", bytes: jpeg, filename: "p.jpg" });
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(200);
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
// The GPS marker is gone; the image is strictly smaller than the original.
|
||||
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
|
||||
expect(out.length).toBeLessThan(jpeg.length);
|
||||
// Still an inline image.
|
||||
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
});
|
||||
|
||||
it("serves a Class B document as an opaque attachment (never inline)", async () => {
|
||||
seedMembership({
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
bytes: Buffer.from("docx payload"),
|
||||
filename: "letter.docx",
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
expect(disposition.startsWith("attachment;")).toBe(true);
|
||||
expect(disposition).toContain("letter.docx");
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("never serves a would-be HTML/SVG payload inline (byte-class denies it)", async () => {
|
||||
// HTML/SVG can never be stored (upload deny-list); even if a row carried
|
||||
// such a type, the serving class forces attachment + octet-stream so it
|
||||
// can never script in-origin.
|
||||
for (const mime of ["text/html", "image/svg+xml"]) {
|
||||
seedMembership({ mimeType: mime, bytes: Buffer.from("<svg/>") });
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("attachment");
|
||||
}
|
||||
});
|
||||
|
||||
it("404s a document id not on the link's frozen set", async () => {
|
||||
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await callGet(makeReq("foreign"), ctx("foreign"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a soft-deleted document even if the membership row survives", async () => {
|
||||
seedMembership({
|
||||
mimeType: "application/pdf",
|
||||
bytes: PDF_BYTES,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s when the document owner does not match the token owner", async () => {
|
||||
seedMembership({
|
||||
mimeType: "application/pdf",
|
||||
bytes: PDF_BYTES,
|
||||
userId: "someone-else",
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("serves NOTHING for a revoked/expired/unknown token (gate null) — no decrypt", async () => {
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
// Revocation is enforced BEFORE any decrypt or membership lookup.
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
expect(resolveShareToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves NOTHING for a protected link without a valid unlock cookie", async () => {
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue({
|
||||
tokenHash: "hash-1",
|
||||
passphraseHash: "hash(pass)",
|
||||
} as never);
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(false);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
// The passphrase gate stands in front of any decrypt.
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s when the per-link serve rate limit trips", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("unknown key id");
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Reference in new issue
Block a user