Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated

# Conflicts:
#	prisma/schema.prisma
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 21:03:04 +02:00
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", () => {
+52 -28
View File
@@ -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) });