diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dd594f976..aa2d46dc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12507,12 +12507,12 @@ paths: post: tags: - Account sharing - summary: Invite an account to read this record - description: Offers read access to somebody already registered on this instance; the grant confers nothing until they - accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller - on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a - mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor - re-delegate. + summary: Invite an account to this record + description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing + until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an + authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a + silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can + neither invite nor re-delegate. requestBody: required: true content: @@ -12530,6 +12530,17 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + access: + default: READ + description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, + observations, a medication, a marked dose) and can still edit or delete nothing, including its own. + Omitted means READ, so a client that predates the field keeps working. The level is fixed when the + invitation is written: no endpoint raises a live grant, because that would widen what the delegate + accepted without asking them again. The way up is a new invitation the delegate accepts." + type: string + enum: + - READ + - WRITE required: - identifier responses: @@ -36689,8 +36700,9 @@ components: - write canWrite: type: boolean - description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; - never derive it. + description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It + never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never + derive it." required: - accountId - username diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts new file mode 100644 index 000000000..2507d194e --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -0,0 +1,155 @@ +/** + * `POST /api/account/grants` — the level a request carries is the level the row + * gets. + * + * The assertion is deliberately on the WRITE the route performs rather than on + * the JSON it answers with: a response echoing the request would pass either + * way, and the failure this pins is the one where a field is validated, + * annotated on, and then dropped on the way to the database. So every case + * reads the `data` object handed to `accountGrant.create`. + * + * Where the default lives matters and is asserted here rather than assumed: an + * omitted `access` becomes READ in the invite schema and nowhere else — + * `inviteGrant` takes the level as a required argument. Move the default and + * the third case below goes red. + * + * What this file cannot see: the browser. That the level a person picks on the + * invitation form reaches this route at all is proven in + * `e2e/account-sharing.spec.ts`, which reads the posted body — the component + * test renders the control and stays green with the control wired to a + * constant. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAuth: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; + +const OWNER = { id: "owner-1" }; +const INVITEE = { id: "invitee-1", username: "housemate", displayName: null }; + +function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` object the route's write reached the client with. */ +function writtenRow(): Record { + const calls = vi.mocked(prisma.accountGrant.create).mock.calls; + expect(calls).toHaveLength(1); + return (calls[0][0] as { data: Record }).data; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: OWNER, + } as unknown as Awaited>); + vi.mocked(prisma.user.findFirst).mockResolvedValue( + INVITEE as never as Awaited>, + ); + vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: { + data: Record; + }) => ({ + id: "grant-1", + grantorId: OWNER.id, + granteeId: INVITEE.id, + acceptedAt: null, + revokedAt: null, + revokedBy: null, + lastUsedAt: null, + expiresAt: null, + ...args.data, + })) as never); +}); + +describe("invitation access level", () => { + it("stores WRITE when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "WRITE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("WRITE"); + const body = (await res.json()) as { data: { access: string } }; + expect(body.data.access).toBe("WRITE"); + }); + + it("stores READ when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "READ" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("mints READ for a client that does not send the field", async () => { + // An older client's payload stays valid, and it stays NARROW. This is the + // case that must never widen by accident. + const res = await POST(request({ identifier: "housemate" })); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("refuses a level that is not one of the two, without writing a row", async () => { + const res = await POST( + request({ identifier: "housemate", access: "ADMIN" }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("records the level on the audit row", async () => { + // "Who was given what, and by whom" is the question this row answers. An + // entry naming the invitation without its level answers half of it. + await POST(request({ identifier: "housemate", access: "WRITE" })); + + expect(auditLog).toHaveBeenCalledWith( + "sharing.grant.invited", + expect.objectContaining({ + userId: OWNER.id, + details: expect.objectContaining({ access: "WRITE" }), + }), + ); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 8d5c785a8..8e92d7c15 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -6,7 +6,10 @@ * one question — "who can see my record, and whose can I see" — and because a * client that has to call twice will eventually render half an answer. * - * `POST` offers a grant to somebody already registered on this instance. + * `POST` offers a grant to somebody already registered on this instance, at + * the level the owner chose. There is no route that raises a live grant: the + * level is settled when the invitation is written and the only way to a wider + * one is a fresh invitation the delegate accepts again. * * **Neither is delegable, and that is structural.** Both resolve through bare * `requireAuth()`, which refuses outright while the caller is acting on @@ -111,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { identifier, expiresAt } = parsed.data; + const { identifier, expiresAt, access } = parsed.data; // Either column, case-insensitively — the same lookup the login form does, // so the name somebody signs in with is the name they can be invited by. @@ -129,18 +132,30 @@ export const POST = apiHandler(async (request: NextRequest) => { } try { - // `inviteGrant` decides everything about the row: READ and only READ, the + // `inviteGrant` decides everything about the row except the level: the // self-grant refusal, and the one-live-grant-per-pair rule the partial - // unique index backs. None of it is re-decided here. + // unique index backs. Neither is re-decided here. The level travels from + // the parsed body, where the schema is the one place an omitted field + // becomes READ, and it is fixed for the life of the row — the domain + // module has no transition that raises it. const grant = await inviteGrant({ grantorId: user.id, granteeId: invitee.id, + access, expiresAt: expiresAt ? new Date(expiresAt) : null, }); + // The level is on the audit row, not only in the response: "who was given + // what, and by whom" is the question this row exists to answer, and an + // entry that recorded the invitation without its level would answer half + // of it. await auditLog("sharing.grant.invited", { userId: user.id, - details: { grantId: grant.id, granteeId: invitee.id }, + details: { + grantId: grant.id, + granteeId: invitee.id, + access: grant.access, + }, ipAddress: getClientIp(request), }).catch(() => {}); @@ -148,6 +163,7 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "sharing.grant.invited" }, meta: { grant_id: grant.id, + access: grant.access, expires: expiresAt !== null && expiresAt !== undefined, }, }); diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d8224ce36..7ec3bb253 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -32,7 +32,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; inviteGrantSchema.meta({ id: "AccountGrantInvite", description: - "Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.", + "Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. The level is fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", }); switchAccountSchema.meta({ @@ -205,7 +205,7 @@ const accountAccessEntry = z canWrite: z .boolean() .describe( - "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never derive it.", ), }) .meta({ @@ -327,9 +327,9 @@ export const accountSharingPaths: NonNullable = { }, post: { tags: ["Account sharing"], - summary: "Invite an account to read this record", + summary: "Invite an account to this record", description: - "Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", + "Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", requestBody: { required: true, content: { "application/json": { schema: inviteGrantSchema } }, diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index f0708338d..86153cf7a 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -16,9 +16,11 @@ import { describe, expect, it } from "vitest"; import { grantAllows, grantState, + inviteGrant, isGrantActive, type GrantLifecycle, } from "@/lib/sharing/grants"; +import type { AccountGrant, Prisma } from "@/generated/prisma/client"; const NOW = new Date("2026-08-02T12:00:00.000Z"); const EARLIER = new Date("2026-08-01T12:00:00.000Z"); @@ -131,3 +133,82 @@ describe("grantAllows", () => { } }); }); + +/** + * The level the caller offered is the level the row is written with. + * + * A capture client rather than a database: what is asserted here is the `data` + * object this module hands the client, which is the one thing a fake can see + * honestly. That the column then holds the value, that the enum accepts it and + * that the partial unique index still applies are properties of Postgres and + * live in `tests/integration/account-grant-lifecycle.test.ts` — a fake that + * returned whatever it was given would report all three as working. + * + * There is no upgrade test because there is no upgrade: no function in this + * module raises the level of a row that exists, and the way to a wider grant + * is a new invitation the delegate accepts again. + */ +describe("inviteGrant", () => { + function captureDb(): { + db: Pick; + writes: Array<{ access: string; grantorId: string; granteeId: string }>; + } { + const writes: Array<{ + access: string; + grantorId: string; + granteeId: string; + }> = []; + const db = { + accountGrant: { + create: async (args: { + data: { access: string; grantorId: string; granteeId: string }; + }) => { + writes.push(args.data); + return { id: "grant-1", ...args.data } as unknown as AccountGrant; + }, + }, + } as unknown as Pick; + return { db, writes }; + } + + it("writes the level it was given", async () => { + for (const access of ["READ", "WRITE"] as const) { + const { db, writes } = captureDb(); + const row = await inviteGrant( + { grantorId: "owner", granteeId: "delegate", access }, + db, + ); + expect(writes).toHaveLength(1); + expect(writes[0].access).toBe(access); + expect(row.access).toBe(access); + } + }); + + it("still refuses a self-grant before anything is written", async () => { + const { db, writes } = captureDb(); + await expect( + inviteGrant( + { grantorId: "solo", granteeId: "solo", access: "WRITE" }, + db, + ), + ).rejects.toMatchObject({ code: "self_grant" }); + expect(writes).toHaveLength(0); + }); + + it("confers the offered level only after acceptance", () => { + // The two halves of the write consent, as the state machine sees them: the + // owner's is the row, the delegate's is `acceptedAt`. Neither alone is + // enough, and the pending row proves it. + const offered = { + revokedAt: null, + expiresAt: null, + access: "WRITE" as const, + }; + expect(grantAllows({ ...offered, acceptedAt: null }, "write", NOW)).toBe( + false, + ); + expect(grantAllows({ ...offered, acceptedAt: EARLIER }, "write", NOW)).toBe( + true, + ); + }); +}); diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 1a0fe3f54..74793b625 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -14,11 +14,12 @@ * `canSwitch` and `canWrite`; it never computes them, and there is nothing in * the payload it could compute them from. * - * `canWrite` is `false` for every entry in v1, because `inviteGrant` writes - * READ and only READ. It is resolved here through {@link grantAllows} against - * the real row rather than hardcoded, so the day a WRITE grant exists the - * answer changes without this file being edited — and, more importantly, so - * that nobody has to remember that it should. + * `canWrite` is resolved here through {@link grantAllows} against the real + * row rather than stored or hardcoded, which is why the level becoming an + * invitation choice needed no edit to this file: an accepted WRITE grant + * started answering true the moment one could exist. It answers "may add to + * this record" and never "may change it" — editing and deleting stay with the + * owner at both levels, and no field here offers them. * * What is deliberately NOT published: the other account's avatar. The avatar * bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index fb387b469..c3765a3be 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -2,10 +2,11 @@ * v1.36.0 — the account-grant state machine. One file, one set of rules. * * A grant is one account's standing permission to read another account's - * record, and the row is also the durable record of the consent that created - * it (there is no second receipt table — see the model docblock in - * `prisma/schema.prisma`). Everything that decides what a grant currently - * MEANS lives here, and nothing else in the tree is allowed to decide it. + * record, and since v1.36.x to add to it as well, and the row is also the + * durable record of the consent that created it (there is no second receipt + * table — see the model docblock in `prisma/schema.prisma`). Everything that + * decides what a grant currently MEANS lives here, and nothing else in the + * tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -20,7 +21,7 @@ * Bearer perimeter that shouts at a mention is the right direction to be * wrong in.) * - * Three properties are the whole point, and each is a decision made HERE and + * Four properties are the whole point, and each is a decision made HERE and * nowhere else: * * * **Pending confers nothing.** An invitation is not access. A row with @@ -36,6 +37,11 @@ * the shape it does. Re-inviting the same person mints a NEW row; the * partial unique index in migration 0292 keeps at most one LIVE row per * pair while the history piles up underneath. + * * **The level never widens.** A row is READ or WRITE from the moment it is + * offered, and no transition here raises it. Widening a grant somebody has + * already accepted would change what they agreed to without asking them + * again, and the delegate's consent is half of what makes a write grant + * legitimate. {@link inviteGrant} carries the reasoning. * * Every write below is a CONDITIONAL update — the state the transition * requires is in the `where`, not in a read the caller performed a moment @@ -145,10 +151,8 @@ export function isGrantActive( * * Active first, then the level. A READ grant permits reads only; a WRITE grant * permits both, because write access to a record without read access describes - * nothing anyone asked for. v1 never writes a WRITE row (see - * {@link inviteGrant}), so in v1 this answers `false` for every write — which - * is the fail-closed arm the resolver wants, reached by the data rather than - * by a special case. + * nothing anyone asked for. The state outranks the level in both directions: a + * revoked WRITE grant permits nothing at all, and no level survives an expiry. */ export function grantAllows( grant: GrantLifecycle & { access: AccountGrantAccess }, @@ -166,6 +170,14 @@ export interface InviteGrantInput { grantorId: string; /** The account being invited to read it. */ granteeId: string; + /** + * What the invitation offers. Required, with no default in this module: a + * level nobody named is a level nobody chose, and the one place an omitted + * request field becomes READ is the invite schema + * (`src/lib/validations/account-sharing.ts`). Two defaults agreeing today is + * how they disagree later. + */ + access: AccountGrantAccess; /** Optional lapse date. Null = the grant runs until somebody ends it. */ expiresAt?: Date | null; } @@ -173,11 +185,16 @@ export interface InviteGrantInput { /** * Offer a grant. The delegate has access only once they accept it. * - * `access` is not a parameter. v1 grants READ and only READ (design §14): the - * column exists from day one so that delegated writes become enforcement work - * instead of migration work, and leaving the level out of the input is what - * makes "v1 never grants write" a property of this file rather than a promise - * in a comment. + * The level is fixed here and never moves. There is no widen, no PATCH, no + * upgrade endpoint, and the absence is the design rather than a gap: write + * access needs two consents, the owner's because it is their record and the + * delegate's because they are being asked to put something into somebody's + * health record under their own name. Acceptance is the single moment those + * two meet, and it happens once. Raising a live grant afterwards would carry + * one consent, and it would not be the delegate's. The way up is a new + * invitation at the level meant, accepted again — the partial unique index + * permits it as soon as the old row is ended, and the cost is one revoke and + * one accept. * * Self-grants are refused here rather than by a database CHECK — migration * 0292 carries the reason (the schema-driven wipe seeder plants a row with @@ -198,7 +215,7 @@ export async function inviteGrant( data: { grantorId: input.grantorId, granteeId: input.granteeId, - access: "READ", + access: input.access, invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index 293291bcd..d2ae406cb 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -5,10 +5,6 @@ * and only two of them carry a body worth validating. What is NOT here is as * deliberate as what is: * - * * No `access` level. v1 grants READ and only READ, and the level is not a - * parameter anywhere in the stack — `inviteGrant` hardcodes it. A field - * here would be an input the domain module then ignores, which is the - * shape of a promise nobody kept. * * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a * grant come from the authenticated session and the row itself. A body * field naming a party would be an authorization decision made by the @@ -43,6 +39,23 @@ export const inviteGrantSchema = z.object({ * remember to renew is worse than one that does not exist. */ expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + /** + * What the invitation offers, and the ONLY place the level is defaulted. + * + * `inviteGrant` takes the level as a required argument, so the domain module + * never guesses; this line is the single answer to "what does an omitted + * field mean", which keeps an older client's payload valid without a second + * copy of the same decision further down. A grant is READ or WRITE from the + * moment it is offered and stays that way: there is no endpoint that widens + * a live grant, because widening it would carry one consent and it would not + * be the delegate's. The way up is a new invitation, accepted again. + */ + access: z + .enum(["READ", "WRITE"]) + .default("READ") + .describe( + "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + ), }); export type InviteGrantBody = z.infer; diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 908c240f4..a1afdb53f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -32,6 +32,7 @@ import { GrantError, acceptGrant, findActiveGrant, + grantAllows, inviteGrant, renounceGrant, revokeGrant, @@ -98,6 +99,7 @@ async function acceptedGrant() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -120,6 +122,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(invited.acceptedAt).toBeNull(); expect(invited.access).toBe("READ"); @@ -147,10 +150,42 @@ describe("account grant lifecycle", () => { expect(active?.id).toBe(invited.id); }); + it("stores the level the invitation offered, and confers it only once accepted", async () => { + // The column, not the argument: a write grant is the one thing in this + // feature that lets somebody put data into another person's record, so the + // proof that WRITE survives the round trip belongs against real Postgres + // rather than against a client fake. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + }); + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(stored.access).toBe("WRITE"); + // Offered is not accepted. Both consents or nothing. + expect(grantAllows(stored, "write")).toBe(false); + + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const live = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(grantAllows(live, "write")).toBe(true); + expect(grantAllows(live, "read")).toBe(true); + }); + it("refuses an account sharing its record with itself", async () => { const solo = await makeUser("solo"); await expect( - inviteGrant({ grantorId: solo.id, granteeId: solo.id }), + inviteGrant({ + grantorId: solo.id, + granteeId: solo.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "self_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(0); }); @@ -163,6 +198,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await expect( @@ -201,6 +237,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() - 1000), }); @@ -215,6 +252,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 60_000), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -331,13 +369,25 @@ describe("account grant lifecycle", () => { it("refuses a second live grant for the same pair", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toBeInstanceOf(GrantError); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "duplicate_live_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(1); }); @@ -352,6 +402,7 @@ describe("account grant lifecycle", () => { const second = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(second.id).not.toBe(first.id); expect(second.acceptedAt).toBeNull(); @@ -384,10 +435,15 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await revokeGrant({ grantId: invited.id, grantorId: owner.id }); } - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); expect( await getPrismaClient().accountGrant.count({ where: { grantorId: owner.id, granteeId: delegate.id }, @@ -484,6 +540,7 @@ describe("account grant lifecycle paths", () => { const liveInvite = await inviteGrant({ grantorId: admin.id, granteeId: live.id, + access: "READ", }); const liveGrant = await acceptGrant({ grantId: liveInvite.id, @@ -492,6 +549,7 @@ describe("account grant lifecycle paths", () => { const droppedInvite = await inviteGrant({ grantorId: admin.id, granteeId: dropped.id, + access: "READ", }); await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); const revoked = await revokeGrant({ diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 37ce752e3..819afec3f 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -162,6 +162,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -260,6 +261,7 @@ describe("acting-account resolver — the decision is never cached", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 400), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -275,7 +277,11 @@ describe("acting-account resolver — the decision is never cached", () => { it("refuses the moment the grant is only pending", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 422e87c25..a304b7ab1 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -98,6 +98,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); @@ -223,6 +224,7 @@ describe("delegated reads coalesce to one row a day", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: second.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: second.id });