mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat: define scoped sharing levels
This commit is contained in:
1 parent
ab14fdb6f3
commit
e4e773d543
4 files changed
+106
-10
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -253,6 +253,10 @@ export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
|
||||
const domains = new Set<ShareDomain>();
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user