diff --git a/src/__tests__/sharing-scope-contract.test.ts b/src/__tests__/sharing-scope-contract.test.ts new file mode 100644 index 000000000..469f69b83 --- /dev/null +++ b/src/__tests__/sharing-scope-contract.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { inviteGrantSchema } from "@/lib/validations/account-sharing"; + +describe("scoped sharing contract", () => { + it("locks the closed eight-domain vocabulary", () => { + expect(SHARE_DOMAINS).toEqual([ + "measurements", + "medications", + "labs", + "profile", + "illness", + "mind", + "cycle", + "documents", + ]); + }); + + it("treats null as whole-record while refusing malformed non-null scopes", () => { + expect(normaliseScope(null)).toBeNull(); + + for (const malformed of [ + [], + ["labs", "labs"], + ["labs", "unknown"], + ["record"], + "labs", + { labs: true }, + ]) { + const resolved = normaliseScope(malformed); + expect(resolved, JSON.stringify(malformed)).not.toBeNull(); + expect(resolved?.size, JSON.stringify(malformed)).toBe(0); + for (const domain of SHARE_DOMAINS) { + expect( + grantCoversDomain({ scopeJson: malformed }, domain), + `${JSON.stringify(malformed)} must not open ${domain}`, + ).toBe(false); + } + } + }); + + it("accepts whole or non-empty unique READ and WRITE scopes only", () => { + for (const access of ["READ", "WRITE"] as const) { + expect( + inviteGrantSchema.safeParse({ + identifier: "delegate", + access, + scope: null, + }).success, + ).toBe(true); + expect( + inviteGrantSchema.safeParse({ + identifier: "delegate", + access, + scope: ["labs", "medications"], + }).success, + ).toBe(true); + } + + expect( + inviteGrantSchema.safeParse({ + identifier: "delegate", + access: "READ", + scope: ["labs", "labs"], + }).success, + ).toBe(false); + }); + + it("allows MANAGE only for the whole record", () => { + expect( + inviteGrantSchema.safeParse({ + identifier: "delegate", + access: "MANAGE", + scope: null, + }).success, + ).toBe(true); + expect( + inviteGrantSchema.safeParse({ + identifier: "delegate", + access: "MANAGE", + scope: ["labs"], + }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index 7dd666f9d..d3aa064f1 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -422,13 +422,15 @@ describe("inviteGrant, on scope", () => { ); }); - it("writes the sections it was given, deduplicated", async () => { + it("refuses duplicate sections rather than widening their meaning", async () => { const { db, writes } = captureScopeDb(); - await inviteGrant( - { ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] }, - db, - ); - expect(writes[0].scopeJson).toEqual(["labs", "medications"]); + await expect( + inviteGrant( + { ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] }, + db, + ), + ).rejects.toMatchObject({ code: "invalid_scope" }); + expect(writes).toHaveLength(0); }); it("refuses a scope that cannot mean anything, before anything is written", async () => { diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 1bd2b1285..908294b4e 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -253,6 +253,10 @@ export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope { const domains = new Set(); for (const entry of stored) { if (!isShareDomain(entry)) return EMPTY_SCOPE; + // A scope is a subset, not merely an array of recognisable values. A + // duplicated key is malformed rather than a second consent to the same + // domain, so it receives no partial credit. + if (domains.has(entry)) return EMPTY_SCOPE; domains.add(entry); } return domains; @@ -361,6 +365,9 @@ export async function inviteGrant( if (input.access === "MANAGE") throw new GrantError("invalid_scope"); if (scope.length === 0) throw new GrantError("invalid_scope"); if (!scope.every(isShareDomain)) throw new GrantError("invalid_scope"); + if (new Set(scope).size !== scope.length) { + throw new GrantError("invalid_scope"); + } } const now = new Date(); @@ -377,10 +384,7 @@ export async function inviteGrant( // to open everything. The two nulls are one keystroke apart and mean // opposite things here. // - // Deduplicated on the way in: a set the owner ticked twice is one - // section, and storing it twice would make two rows with the same - // meaning look different to anything that reads them back. - scopeJson: scope === null ? Prisma.DbNull : [...new Set(scope)], + scopeJson: scope === null ? Prisma.DbNull : scope, invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index a8b995d87..ee25a5b9d 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -80,6 +80,9 @@ export const inviteGrantSchema = z scope: z .array(z.enum(SHARE_DOMAINS)) .min(1) + .refine((scope) => new Set(scope).size === scope.length, { + message: "Each section can be selected only once", + }) .nullable() .optional() .describe(