mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(sharing): keep a scoped grant out of the record's module configuration
Publishing the active record's module map and cycle flag on the account payload was right for a whole-record grant and too generous for a narrowed one. `accountAccess.active` is set for any live grant, level and scope included, so a delegate scoped to `measurements` read whether the owner tracks their cycle, their screeners, their illness episodes and their supplement intake, and the cycle flag derives from the owner's recorded sex. Both fields are now masked to the sections the active grant names: a closed section answers `false`, which is the answer a module that is off already gives and the answer the browser's route inventory has always acted on. An unscoped grant names the whole record and is unaffected, so the own-record and whole-record payloads are byte-identical to what they were. The masking table lives beside the sharing vocabulary rather than inside it, and runs one way only: no value in it can turn a module on, admit a read or widen a grant.
This commit is contained in:
1 parent
ccca056f64
commit
0cccb4de99
3 files changed
+223
-14
No files matched your search
@@ -24,6 +24,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
@@ -83,7 +85,9 @@ async function signIn(userId: string) {
|
||||
interface MePayload {
|
||||
modules: Record<string, boolean>;
|
||||
cycleTrackingEnabled: boolean;
|
||||
accountAccess: { active: { accountId: string } | null };
|
||||
accountAccess: {
|
||||
active: { accountId: string; sections: string[] | null } | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function readMe(): Promise<MePayload> {
|
||||
@@ -320,3 +324,71 @@ describe("who may write the record's module map", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a SCOPED grant is told about the record's modules", () => {
|
||||
/** An owner who tracks everything, and a delegate holding one section. */
|
||||
async function delegateInside(scope: ShareDomain[] | null) {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
|
||||
const grant = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope,
|
||||
});
|
||||
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
|
||||
const session = await signIn(delegate.id);
|
||||
await switchSessionTo(session.id, owner.id);
|
||||
return { owner, delegate };
|
||||
}
|
||||
|
||||
it("answers false for every module outside the granted sections", async () => {
|
||||
// The disclosure the record scoping opened: `active` is set for ANY live
|
||||
// grant, so a delegate holding `measurements` would otherwise read the
|
||||
// owner's cycle, screener, illness and lab configuration — and the cycle
|
||||
// flag derives from the owner's recorded sex.
|
||||
const { owner } = await delegateInside(["measurements"]);
|
||||
|
||||
const payload = await readMe();
|
||||
expect(payload.accountAccess.active?.accountId).toBe(owner.id);
|
||||
expect(payload.accountAccess.active?.sections).toEqual(["measurements"]);
|
||||
|
||||
// The owner really does track these — masked, not merely absent.
|
||||
const { resolveModuleMap } = await import("@/lib/modules/gate");
|
||||
const truth = await resolveModuleMap(owner.id);
|
||||
for (const key of [
|
||||
"cycle",
|
||||
"mood",
|
||||
"mentalHealth",
|
||||
"illness",
|
||||
"labs",
|
||||
"medications",
|
||||
"inboundDocuments",
|
||||
] as const) {
|
||||
expect(truth[key], `the record's own ${key}`).toBe(true);
|
||||
expect(payload.modules[key], `the delegate's ${key}`).toBe(false);
|
||||
}
|
||||
expect(payload.cycleTrackingEnabled).toBe(false);
|
||||
|
||||
// The granted section still answers honestly, or the mask would be a
|
||||
// blackout rather than a scope.
|
||||
for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
|
||||
expect(payload.modules[key], `the delegate's ${key}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an unscoped grant reading the record's true map", async () => {
|
||||
// `scope: null` is the whole record, which every grant written before
|
||||
// scoping carries. Masking it would break the case the record scoping was
|
||||
// built for.
|
||||
const { owner } = await delegateInside(null);
|
||||
|
||||
const payload = await readMe();
|
||||
expect(payload.accountAccess.active?.sections).toBeNull();
|
||||
|
||||
const { resolveModuleMap } = await import("@/lib/modules/gate");
|
||||
expect(payload.modules).toEqual(await resolveModuleMap(owner.id));
|
||||
expect(payload.cycleTrackingEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user