From 3a99de312e895e38b9bc49a2a65dcc05827b6bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 18:54:20 +0200 Subject: [PATCH] fix(02-12.1): check record context before an idempotent replay - evaluate the fence in the wrapper above findCached, so a stale request neither replays a body, learns a cell exists, nor inserts a claim - return the refusal rather than throwing: one route composes the wrapper outside apiHandler, where a throw escapes the envelope - read the claim and the session's own context on one session lookup --- .../acting-account-boundary-guard.test.ts | 12 +- .../record-session-fence-guard.test.ts | 22 ++ src/lib/__tests__/idempotency.test.ts | 160 ++++++++++ src/lib/auth/acting-carrier.ts | 42 ++- src/lib/idempotency.ts | 28 +- src/lib/sharing/record-session-fence.ts | 88 ++++-- .../managed-profile-lifecycle.test.ts | 12 +- tests/integration/mock-next-headers.ts | 28 ++ .../record-session-fence-idempotency.test.ts | 283 ++++++++++++++++++ tests/integration/setup.ts | 29 ++ .../sharing-account-access.test.ts | 12 +- tests/integration/sharing-audit-actor.test.ts | 17 +- .../sharing-delegable-routes.test.ts | 32 +- .../sharing-delegable-writes.test.ts | 12 +- tests/integration/sharing-front-door.test.ts | 12 +- tests/integration/sharing-lifecycle.test.ts | 46 ++- .../sharing-manage-handler-matrix.test.ts | 12 +- tests/integration/sharing-scope-fence.test.ts | 12 +- 18 files changed, 731 insertions(+), 128 deletions(-) create mode 100644 tests/integration/record-session-fence-idempotency.test.ts diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 42cf7af48..ad7666d33 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -139,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => { * Who may ask "which record does this request claim" without an auth * context in hand. * - * `readClaimedActingAccount()` answers before any grant check has run, so a + * `readClaimedRecordContext()` answers before any grant check has run, so a * caller that treats its answer as permission has skipped the only thing * that grants any. Exactly one caller exists, beside the file that declares * it: the idempotency wrapper, which uses the claim to pick a cache cell and @@ -147,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => { * acting account has an auth context by then and goes through * `requireRecordAuth`, which checks a live grant before returning. A second * caller here is a design question, not a patch. + * + * The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well + * as today's. That function was removed in v1.37.0 when the wrapper started + * needing the session's record context on the same read; matching both means + * a reintroduced narrow copy trips this leg instead of quietly becoming a + * second answer. */ it("the unchecked claim has exactly two consumers", () => { - const consumers = filesMatching(/readClaimedActingAccount/); + const consumers = filesMatching( + /readClaimedRecordContext|readClaimedActingAccount/, + ); expect(consumers.length).toBeGreaterThan(0); expect(consumers).toEqual([ "lib/auth/acting-carrier.ts", diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts index 7d1f6de0d..bb81b3d3f 100644 --- a/src/__tests__/record-session-fence-guard.test.ts +++ b/src/__tests__/record-session-fence-guard.test.ts @@ -215,10 +215,32 @@ describe("the epoch has one writer and a closed reader set", () => { "lib/api-handler.ts", // Compares it. The one place that decides anything from it. "lib/sharing/record-session-fence.ts", + // Reads it off the same session lookup the idempotency wrapper already + // makes, so the fence can be evaluated above the replay cache without a + // second round trip. + "lib/auth/acting-carrier.ts", ].sort(), ); }); + it("is consulted by the idempotency wrapper, above the cache", () => { + const src = read("lib/idempotency.ts"); + const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)"); + const cache = src.indexOf("findCached(ctx)"); + const claimRow = src.indexOf("claimKey(ctx)"); + expect(fence).toBeGreaterThan(-1); + expect(cache).toBeGreaterThan(-1); + expect(claimRow).toBeGreaterThan(-1); + // A stale request must not learn whether a cell exists, replay a body out + // of one, or insert a claim row. The runtime proof is the zero-call + // instrument in `src/lib/__tests__/idempotency.test.ts`; this is the + // tripwire that says the line moved. + expect(fence).toBeLessThan(cache); + expect(fence).toBeLessThan(claimRow); + // And it comes from the fence module, not from a second copy of the rule. + expect(src).toContain('from "@/lib/sharing/record-session-fence"'); + }); + it("does not leak the raw counter into the browser's vocabulary", () => { // The client never names the epoch as a column. It receives // `recordSession: { epoch, scope }` and asserts it back as a header, which diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index eb8d41fae..0476131c5 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -26,6 +26,11 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), + // v1.37.0 — the record-session fence stamps the response echo on the + // request-scoped wide event. There is no event outside a request scope and + // the fence handles that with an optional call, so `undefined` is the honest + // stub rather than a fake builder. + getEvent: vi.fn(() => undefined), })); vi.mock("@/lib/auth/session", () => ({ @@ -674,3 +679,158 @@ describe("withIdempotency — record-scoped cells", () => { expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); }); }); + +/** + * v1.37.0 — the record-session fence, above the replay cache. + * + * The property is a PLACEMENT, and a status-code assertion cannot see it: a + * wrapper that read the cell, replayed nothing, released its claim and then + * returned 409 would look identical from outside. `findCached` and `claimKey` + * are module-private, so the instrument is one layer lower and falsifiable: the + * two Prisma calls they make must receive zero calls for a refused request and + * non-zero for a passing one. + * + * The first draft of this plan proposed asserting "no row exists for the target + * cell" instead. That assertion cannot fail: `releaseClaim` deletes the pending + * row whenever the handler throws, so it would pass with the fence placed after + * `claimKey` too. + * + * Break it by moving the fence call below `findCached` in `withIdempotency`: + * the `findUnique` zero-call assertion fails. + */ +describe("the record-session fence sits above the replay cache", () => { + function fencedSession(recordEpoch: number, actingAsUserId: string | null) { + vi.mocked(getSession).mockResolvedValue({ + session: { + id: "s-1", + expiresAt: new Date(Date.now() + 60_000), + actingAsUserId, + recordEpoch, + }, + user: { id: "u-1" }, + } as never); + } + + function asserts(epoch: string | null, scope: string | null) { + vi.mocked(headers).mockResolvedValue({ + get: vi.fn((name: string) => { + if (name.toLowerCase() === "x-healthlog-record-epoch") return epoch; + if (name.toLowerCase() === "x-healthlog-record-scope") return scope; + return null; + }), + } as never); + } + + it("refuses a stale claim without reading or claiming a cell", async () => { + fencedSession(4, "owner-1"); + asserts("3", "owner-1"); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe("sharing.session.changed"); + // The placement, stated as the only thing that can prove it. + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + // And the side effect the whole wrapper exists to run once never ran. + expect(handler).not.toHaveBeenCalled(); + }); + + it("POSITIVE CONTROL: a matching claim reads the cell and claims it", async () => { + // Without this the assertion above would pass for a wrapper that never + // touched the cache at all. + fencedSession(4, "owner-1"); + asserts("4", "owner-1"); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(201); + expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("lets an unfenced client through to the route's own refusal", async () => { + // `unfenced-client` is deliberately NOT refused here: the route owns the + // 403 a pre-fence bundle recovers from, together with its annotation and + // audit posture. The wrapper still files the cell, because the handler is + // about to answer. + fencedSession(4, "owner-1"); + asserts(null, null); + const handler = vi.fn(async () => + NextResponse.json( + { data: null, error: "Account access denied" }, + { status: 403 }, + ), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(403); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("leaves a never-switched session byte-identical", async () => { + fencedSession(0, null); + asserts(null, null); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(201); + expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + }); + + it("leaves the Bearer transport byte-identical", async () => { + // No session row at all, so no context to be stale about — and a fence + // header on this transport is neither read nor honoured. + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + userId: "u-bearer", + revoked: false, + expiresAt: null, + } as never); + vi.mocked(headers).mockResolvedValue({ + get: vi.fn((name: string) => { + if (name.toLowerCase() === "authorization") + return `Bearer hlk_${"a".repeat(64)}`; + if (name.toLowerCase() === "x-healthlog-record-epoch") return "1"; + if (name.toLowerCase() === "x-healthlog-record-scope") return "owner-1"; + return null; + }), + } as never); + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler); + + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(201); + expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts index 055a708ec..851ab7193 100644 --- a/src/lib/auth/acting-carrier.ts +++ b/src/lib/auth/acting-carrier.ts @@ -191,9 +191,33 @@ export async function getUnswitchedSession(): Promise { +/** + * v1.37.0 — and, on the same read, what that request's session actually holds. + * + * This replaced a narrower `readClaimedActingAccount()` that returned only the + * claim. The idempotency wrapper — still the one caller — now needs a second + * fact: the session's own record context, so the record-session fence can be + * evaluated above the replay cache. Widening the existing function rather than + * adding a second one is what keeps that from costing a second `getSession()` + * on every idempotent write, and keeps "what does this request say it is acting + * as" to one implementation, which is the thing this file exists to ensure. + * + * The narrow function is gone rather than kept as a shim: it had no caller left, + * and a wrapper nobody calls is a second answer waiting for somebody to reach + * for it. + */ +export interface ClaimedRecordContext { + /** The claimed record, or `undefined` when the carrier was unsafe to resolve. */ + claimedRecord: string | null | undefined; + /** Which credential the request authenticated with. */ + transport: "cookie" | "bearer"; + /** The session's selector-move counter. Always 0 on the Bearer transport. */ + sessionEpoch: number; + /** The account the session is pointed at, or null. Always null on Bearer. */ + sessionScope: string | null; +} + +export async function readClaimedRecordContext(): Promise { const header = await readSelectorHeader(); let session: Awaited> = null; try { @@ -204,11 +228,17 @@ export async function readClaimedActingAccount(): Promise< // unauthenticated request claims nothing. session = null; } + const transport: "cookie" | "bearer" = session ? "cookie" : "bearer"; const carrier = decideActingCarrier({ - transport: session ? "cookie" : "bearer", + transport, stamped: session?.session.actingAsUserId ?? null, header, }); - if (carrier.kind === "misplaced-header") return undefined; - return carrierTarget(carrier); + return { + claimedRecord: + carrier.kind === "misplaced-header" ? undefined : carrierTarget(carrier), + transport, + sessionEpoch: session?.session.recordEpoch ?? 0, + sessionScope: session?.session.actingAsUserId ?? null, + }; } diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 28fb170a1..40186739f 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -18,9 +18,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { - readClaimedActingAccount, + readClaimedRecordContext, selectorNamesAnAccount, } from "@/lib/auth/acting-carrier"; +import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fence"; import { hashToken } from "@/lib/auth/hmac"; import { annotate } from "@/lib/logging/context"; import { isP2002 } from "@/lib/prisma-errors"; @@ -327,7 +328,7 @@ export function isCachableStatus(status: number): boolean { * the handler again and creating duplicate measurements (audit C-4). * * It resolves the CALLER and only the caller — which record they are aiming at - * is a separate question, answered by `readClaimedActingAccount()` above and + * is a separate question, answered by `readClaimedRecordContext()` above and * folded into the key. Neither answer authorises anything: the handler runs * its own `requireAuth()` / `requireRecordAuth()` either way. * @@ -456,7 +457,28 @@ export function withIdempotency< // A claim only chooses the cache cell. It never carries authority: a // completed delegated cell is checked against the current grant again // immediately before its body can be returned below. - const claimedRecord = await readClaimedActingAccount(); + const claim = await readClaimedRecordContext(); + + // v1.37.0 — the record-session fence, evaluated HERE and not one line + // lower. A stale request must not learn whether a cell exists, must not + // replay a body out of one, and must not insert a claim row — so the + // verdict sits above `findCached` and above the two carrier arms below, + // which choose the cell. It is the second, independent evaluation of the + // same client assertion (the handler's own `requireRecordAuth` is the + // other) and nothing is carried between them; see the note on + // `refuseStaleRecordSessionClaim`. + // + // Only `stale` refuses here. An `unfenced-client` verdict falls through so + // the route issues the 403 a pre-fence bundle already recovers from, with + // the annotation and audit posture that belong to the route. + // + // A never-switched session and every Bearer request are untouched: both + // pass the verdict without a header, which is what keeps every existing + // own-record and delegated-cell contract byte-identical. + const staleContext = await refuseStaleRecordSessionClaim(claim); + if (staleContext) return staleContext; + + const claimedRecord = claim.claimedRecord; if (claimedRecord === undefined) { // The carrier cannot safely name a record. In particular, a selector on // a cookie request is a misplaced claim, not an own-record request. diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts index 0f0b2921e..61ab2cdb8 100644 --- a/src/lib/sharing/record-session-fence.ts +++ b/src/lib/sharing/record-session-fence.ts @@ -40,9 +40,11 @@ * discards a response whose echoed context contradicts the one it is now on. */ import { headers } from "next/headers"; +import { NextResponse } from "next/server"; import { RECORD_EPOCH_HEADER, + RECORD_FENCE_ERROR_CODE, RECORD_SCOPE_HEADER, parseAssertedContext, recordScopeHeaderValue, @@ -143,23 +145,10 @@ export function recordSessionForPayload( export async function assertRecordSessionFence( auth: AuthContext, ): Promise { - const transport = auth.authMethod === "bearer" ? "bearer" : "cookie"; - const sessionEpoch = auth.session.recordEpoch ?? 0; - const sessionScope = auth.session.actingAsUserId ?? null; - - // Stamped before the verdict, so a refusal echoes the truth too. The Bearer - // transport carries no session context to echo and gets none: the frozen - // native contract sends no fence header and receives none back. - if (transport === "cookie") { - getEvent()?.setRecordContext({ epoch: sessionEpoch, scope: sessionScope }); - } - - const asserted = await readAssertedRecordContext(); - const verdict = recordSessionFenceVerdict({ - transport, - sessionEpoch, - sessionScope, - asserted, + const verdict = await decide({ + transport: auth.authMethod === "bearer" ? "bearer" : "cookie", + sessionEpoch: auth.session.recordEpoch ?? 0, + sessionScope: auth.session.actingAsUserId ?? null, }); if (verdict === "pass") return; @@ -183,3 +172,68 @@ export async function assertRecordSessionFence( annotate({ meta: { sharing_refusal: "unfenced_client" } }); throw new SharingAccessDeniedError(); } + +/** + * The same verdict, for the idempotency wrapper, returning bytes instead of + * throwing. + * + * `withIdempotency` is composed OUTSIDE `apiHandler` on at least one route + * (`POST /api/admin/backups/[id]/restore`), so a throw from the wrapper would + * escape the envelope and become a 500 rather than the documented refusal. It + * therefore returns a response, hand-built to the same + * `{ data, error, meta.errorCode }` shape `apiHandler`'s `SharingAuthError` arm + * emits, so a client cannot tell which layer refused it. + * + * Only the `stale` arm produces a response. `unfenced-client` deliberately + * falls through to the handler, whose `requireRecordAuth` issues the 403 a + * pre-fence bundle recovers from — along with the wide-event annotation and the + * audit posture that belong to the route, not to a cache wrapper. + * + * This is the SECOND, INDEPENDENT evaluation of the fence on such a request, + * and its result is deliberately NOT carried into the handler's own. Carrying + * it would need request-scoped mutable state shared across the wrapper/handler + * boundary, which is a second source of truth for the same question. Two + * independent evaluations against the same client assertion are sufficient: a + * switch landing between the two fails the second, so no handler body runs + * under a scope the asserted epoch does not imply. + */ +export async function refuseStaleRecordSessionClaim( + claim: RecordSessionInputs, +): Promise { + const verdict = await decide(claim); + if (verdict !== "stale") return null; + annotate({ meta: { sharing_refusal: "record_context_stale_idempotent" } }); + return NextResponse.json( + { + data: null, + error: "The record this session is on has changed", + meta: { errorCode: RECORD_FENCE_ERROR_CODE }, + }, + { status: 409 }, + ); +} + +/** What the fence needs to know about the session, from either caller. */ +interface RecordSessionInputs { + transport: "cookie" | "bearer"; + sessionEpoch: number; + sessionScope: string | null; +} + +/** Stamp the echo and read the verdict. The one place either happens. */ +async function decide(input: RecordSessionInputs) { + // Stamped before the verdict, so a refusal echoes the truth too. The Bearer + // transport carries no session context to echo and gets none: the frozen + // native contract sends no fence header and receives none back. + if (input.transport === "cookie") { + getEvent()?.setRecordContext({ + epoch: input.sessionEpoch, + scope: input.sessionScope, + }); + } + + return recordSessionFenceVerdict({ + ...input, + asserted: await readAssertedRecordContext(), + }); +} diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 39c7219e2..0f7e14872 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; import { createManagedProfile } from "@/lib/managed-profiles/create"; import { acceptGrant, @@ -324,10 +324,7 @@ describe("managed profile lifecycle (real Postgres)", () => { where: { id: invitation.id }, }), ).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null }); - await getPrismaClient().session.update({ - where: { id: secondGuardian.sessionId }, - data: { actingAsUserId: profile.id }, - }); + await switchSessionTo(secondGuardian.sessionId, profile.id); cookieJar.set("healthlog_session", creator.sessionId); const { DELETE: revokeGuardian } = @@ -505,10 +502,7 @@ describe("managed profile lifecycle (real Postgres)", () => { const { creator, profile, secondGuardian, secondGrant } = await createTwoGuardians(); cookieJar.set("healthlog_session", creator.sessionId); - await getPrismaClient().session.update({ - where: { id: secondGuardian.sessionId }, - data: { actingAsUserId: profile.id }, - }); + await switchSessionTo(secondGuardian.sessionId, profile.id); const prisma = getPrismaClient(); await prisma.$executeRawUnsafe( "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts index edce8a7cc..f6de28c2e 100644 --- a/tests/integration/mock-next-headers.ts +++ b/tests/integration/mock-next-headers.ts @@ -24,3 +24,31 @@ export const headerJar = new Map(); * A request consumes one entry when its mocked cookie store is created. */ export const queuedSessionIds: string[] = []; + +/** + * v1.37.0 — attach the record-session assertion the shipped browser client + * attaches to every same-origin request. + * + * The record-session fence refuses a cookie request whose asserted context does + * not match its session row, and it refuses a request that asserts nothing at + * all once the session has been inside somebody's record. A test that moves + * `acting_as_user_id` directly and then calls a route is therefore exercising + * the fence's pre-fence-bundle arm, not whatever the test was written for — so + * every such fixture calls this immediately after the switch. + * + * Truthful by construction: the values are read back off the row rather than + * computed, so this can only ever say what the session actually holds. The + * cases that want a STALE or ABSENT assertion set the headers themselves, and + * they live in `tests/integration/record-session-fence*.test.ts`. + * + * Takes the epoch and scope rather than reading the database, so this module + * keeps its single responsibility (shared mock state) and no test file has to + * import a Prisma client it does not otherwise use. + */ +export function assertRecordContext( + recordEpoch: number, + actingAsUserId: string | null, +): void { + headerJar.set("x-healthlog-record-epoch", String(recordEpoch)); + headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self"); +} diff --git a/tests/integration/record-session-fence-idempotency.test.ts b/tests/integration/record-session-fence-idempotency.test.ts new file mode 100644 index 000000000..e241671ae --- /dev/null +++ b/tests/integration/record-session-fence-idempotency.test.ts @@ -0,0 +1,283 @@ +/** + * FENCE-AC-05 — an idempotent write whose record context moved between the + * client forming its intent and the server receiving it. + * + * The dangerous shape is specific. A tab posts a mutation to the owner's record + * with an `Idempotency-Key`, the selector moves out of band, and the retry + * arrives. Without a fence above the cache the retry either replays the owner's + * cached body under the new context, or files a fresh claim in a cell it has no + * business choosing. Either way nothing errors. + * + * ## What is asserted, and what deliberately is not + * + * The seeded owner cell is asserted BYTE-UNCHANGED — same id, same + * `responseStatus`, same `responseBody`, same `expiresAt` — because that is + * falsifiable. `expiresAt` carries the weight: the completion path rewrites it + * to `now + 24h` and a fresh claim rewrites it to `now + 2min`, so a wrapper + * that reached the cache at all would move it. The obvious alternative, + * "no row exists for the target cell", is not: `releaseClaim` deletes the + * pending row whenever the handler throws, so that assertion passes with the + * fence placed after `claimKey` too. The zero-call proof for `findUnique` / + * `create` lives one layer down in `src/lib/__tests__/idempotency.test.ts`, + * where the Prisma calls can be spied on directly. + * + * Everything under the test is real: the shipped wrapper, the shipped resolver, + * the composite unique index, the trigger that moves the epoch. + */ +import { NextRequest, NextResponse } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { + RECORD_EPOCH_HEADER, + RECORD_FENCE_ERROR_CODE, + RECORD_SCOPE_HEADER, + RECORD_SCOPE_SELF, +} from "@/lib/sharing/record-session-fence-contract"; + +const KEY = "retry-7c21ab04"; + +let counter = 0; +let handlerRuns = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `fence-idem-${suffix}`, + email: `fence-idem-${suffix}@example.test`, + role: "USER", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function writeGrant(grantorId: string, granteeId: string) { + return getPrismaClient().accountGrant.create({ + data: { + grantorId, + granteeId, + access: "WRITE", + acceptedAt: new Date(Date.now() - 60_000), + }, + }); +} + +/** The shipped composition: `apiHandler(withIdempotency(handler))`. */ +const delegableWrite: (request: NextRequest) => Promise = apiHandler( + withIdempotency<[NextRequest]>(async () => { + const { user } = await requireRecordAuth("write", "measurements"); + // Counted AFTER the resolver, so it means "the handler body ran" rather + // than "the wrapper called through". The wrapper deliberately calls + // through on an `unfenced-client` verdict so the route can issue its own + // refusal, and a counter incremented above this line would report that + // pass-through as a side effect having happened. + handlerRuns += 1; + const row = await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 80 + handlerRuns, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); + return NextResponse.json( + { data: { id: row.id, userId: row.userId }, error: null }, + { status: 201 }, + ); + }), +); + +function post(): Promise { + return delegableWrite( + new NextRequest("http://localhost/api/test/delegable-write", { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": KEY, + }, + body: JSON.stringify({ value: 81 }), + }), + ); +} + +function assertContext(epoch: number, scope: string | null): void { + headerJar.set(RECORD_EPOCH_HEADER, String(epoch)); + headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF); +} + +async function switchOutOfBand(sessionId: string, target: string | null) { + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { actingAsUserId: target }, + }); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + return row.recordEpoch; +} + +async function cells() { + return getPrismaClient().idempotencyKey.findMany({ orderBy: { key: "asc" } }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + handlerRuns = 0; +}); + +describe("FENCE-AC-05 an idempotent write under a moved record context", () => { + it("FENCE-AC-05 refuses the replay, leaves the seeded owner cell byte-unchanged, and writes to neither record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await writeGrant(owner.id, delegate.id); + const session = await signIn(delegate.id); + + // A completed owner cell for key K, written by the real path. + const inRecord = await switchOutOfBand(session.id, owner.id); + assertContext(inRecord, owner.id); + const first = await post(); + expect(first.status).toBe(201); + expect(handlerRuns).toBe(1); + + const seeded = await cells(); + expect(seeded).toHaveLength(1); + const before = seeded[0]; + expect(before.responseStatus).toBe(201); + + // The selector moves out of band. The tab knows nothing about it and + // retries the same key under the context it formed its intent in. + const afterSwitch = await switchOutOfBand(session.id, null); + expect(afterSwitch).toBe(inRecord + 1); + + const replay = await post(); + const body = await replay.json(); + + // (a) refused + expect(replay.status).toBe(409); + expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE); + + // (b) the seeded cell is untouched — nothing read it, claimed it, or + // rewrote it. `updatedAt` is the falsifiable half: a wrapper that reached + // the cache and released a claim would leave the row present but moved. + const after = await cells(); + expect(after).toHaveLength(1); + expect(after[0].id).toBe(before.id); + expect(after[0].key).toBe(before.key); + expect(after[0].responseStatus).toBe(before.responseStatus); + expect(after[0].responseBody).toBe(before.responseBody); + expect(after[0].expiresAt.getTime()).toBe(before.expiresAt.getTime()); + expect(after[0].createdAt.getTime()).toBe(before.createdAt.getTime()); + + // (c) no write landed on either record: one row from the first request and + // nothing since, on either side. + expect(handlerRuns).toBe(1); + const rows = await getPrismaClient().measurement.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(owner.id); + }); + + it("FENCE-AC-05 POSITIVE CONTROL: the same replay under a matching context returns the cached body", async () => { + // Without this, the assertions above would pass for a wrapper that had + // stopped replaying anything at all. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await writeGrant(owner.id, delegate.id); + const session = await signIn(delegate.id); + + const inRecord = await switchOutOfBand(session.id, owner.id); + assertContext(inRecord, owner.id); + + const first = await post(); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await post(); + const replayBody = await replay.json(); + + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(replayBody.data.id).toBe(firstBody.data.id); + // Replayed, not re-run. + expect(handlerRuns).toBe(1); + expect(await getPrismaClient().measurement.findMany()).toHaveLength(1); + }); + + it("FENCE-AC-05 leaves a never-switched idempotent write byte-identical", async () => { + // No fence header at all, no switch ever. This is the shape every existing + // own-record idempotency contract has, and it must not have moved. + const solo = await makeUser("solo"); + await signIn(solo.id); + + const first = await post(); + expect(first.status).toBe(201); + const replay = await post(); + + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(handlerRuns).toBe(1); + const stored = await cells(); + expect(stored).toHaveLength(1); + // The un-delegated cell files under the client's key verbatim — the record + // fold does not touch a request that is nobody's delegate. + expect(stored[0].key).toBe(KEY); + }); + + it("FENCE-AC-05 lets a pre-fence bundle reach the route's own refusal", async () => { + // An `unfenced-client` verdict is not the wrapper's to refuse: the route + // owns the 403 that bundle recovers from. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await writeGrant(owner.id, delegate.id); + const session = await signIn(delegate.id); + await switchOutOfBand(session.id, owner.id); + // No fence headers at all. + + const res = await post(); + expect(res.status).toBe(403); + expect((await res.json()).meta.errorCode).toBe("sharing.access.denied"); + expect(handlerRuns).toBe(0); + expect(await getPrismaClient().measurement.findMany()).toHaveLength(0); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 54e0cdebb..3430ad2e6 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -4,6 +4,7 @@ * environment-setup.ts before application modules load. */ import { prisma } from "@/lib/db"; +import { assertRecordContext } from "./mock-next-headers"; import type { PrismaClient } from "@/generated/prisma/client"; /** @@ -118,3 +119,31 @@ export async function truncateAllTables(client: PrismaClient): Promise { }); } } + +/** + * v1.37.0 — point a session at a record the way a test needs it pointed, and + * leave the request headers agreeing with the row. + * + * Every sharing fixture used to do this with a bare `session.update`. That is + * still the write, but since the record-session fence a cookie request also has + * to ASSERT the context it believes it is in: a session pointed at a record and + * a request carrying no assertion is a bundle that predates the fence, and it is + * refused with 403 before any resolver decides anything. A fixture that switched + * and did not assert would therefore be testing the deploy-compatibility arm + * rather than whatever it was written for. + * + * The assertion is read back off the row rather than computed, so it is + * truthful by construction and cannot drift from the trigger's idea of the + * epoch. Tests that WANT a stale or absent assertion set the headers themselves; + * those live in `tests/integration/record-session-fence*.test.ts`. + */ +export async function switchSessionTo( + sessionId: string, + actingAsUserId: string | null, +): Promise { + const row = await prisma.session.update({ + where: { id: sessionId }, + data: { actingAsUserId }, + }); + assertRecordContext(row.recordEpoch, row.actingAsUserId); +} diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts index c66aac061..bb0daef58 100644 --- a/tests/integration/sharing-account-access.test.ts +++ b/tests/integration/sharing-account-access.test.ts @@ -17,7 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -298,10 +298,7 @@ describe("accountAccess — the active record", () => { const delegate = await makeUser("delegate"); await grantAccess(owner.id, delegate.id); const session = await signIn(delegate.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); const { accountAccess, id } = await readMe(); @@ -327,10 +324,7 @@ describe("accountAccess — the active record", () => { const delegate = await makeUser("delegate"); const grant = await grantAccess(owner.id, delegate.id); const session = await signIn(delegate.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); // The grant lapses while the browser sits inside the record. Expiry has // no session cleanup — only revocation does — so the stamp survives it. await getPrismaClient().accountGrant.update({ diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 8c0a871c2..80cc88377 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -17,7 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -103,10 +103,7 @@ async function household() { }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); return { owner, delegate, session }; } @@ -185,10 +182,7 @@ describe("who the trail says did it", () => { const delegate = await makeUser("delegate"); const stranger = await makeUser("stranger"); const session = await signIn(delegate.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: stranger.id }, - }); + await switchSessionTo(session.id, stranger.id); expect((await act()).status).toBe(403); await settle(); @@ -232,10 +226,7 @@ describe("delegated reads coalesce to one row a day", () => { await act(); const secondSession = await signIn(second.id); - await getPrismaClient().session.update({ - where: { id: secondSession.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(secondSession.id, owner.id); await act(); await settle(); diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 8e7c44c92..1c4e95ee7 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -99,10 +99,7 @@ async function switchInto(ownerId: string, delegateId: string) { }, }); const session = await signIn(delegateId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); return { grant, session }; } @@ -199,10 +196,7 @@ function contract( await route.seed(owner.id, route.ownerMarker); const session = await signIn(stranger.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); await expectDenied(await route.call()); }); @@ -1108,10 +1102,7 @@ describe("GET /api/sleep/night", () => { const owner = await makeUser("owner"); const stranger = await makeUser("stranger"); const session = await signIn(stranger.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); const { GET } = await import("@/app/api/sleep/night/route"); await expectDenied(await call(GET as Handler, "/api/sleep/night")); @@ -1550,10 +1541,7 @@ describe("GET /api/medications/intake", () => { await seedTodaysDose(owner.id, "owner-statin"); const session = await signIn(stranger.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); await expectDenied(await today()); }); @@ -1682,10 +1670,7 @@ describe("the per-medication reads", () => { }); const session = await signIn(stranger.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); const detail = await import("@/app/api/medications/[id]/route"); await expectDenied( @@ -1882,10 +1867,7 @@ describe("GET /api/documents/inbound", () => { }, }); const session = await signIn(delegate.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); expect(grant.access).toBe("WRITE"); const { POST } = await import("@/app/api/documents/inbound/route"); diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts index 97dcb51de..c3ac2452c 100644 --- a/tests/integration/sharing-delegable-writes.test.ts +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -39,7 +39,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -134,20 +134,14 @@ async function switchInto( expect(grant.acceptedAt).not.toBeNull(); const session = await signIn(delegateId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); return { grant, session }; } /** A session inside a record nobody ever shared with this caller. */ async function claimWithoutGrant(ownerId: string, callerId: string) { const session = await signIn(callerId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); } async function revoke(grantId: string, ownerId: string) { diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 90e819089..36c71d62d 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -32,7 +32,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -105,10 +105,7 @@ async function switchInto( granteeId: delegateId, }); const session = await signIn(delegateId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); return { grant, session }; } @@ -242,10 +239,7 @@ function frontDoorRead(name: string, route: FrontDoorRead) { await route.seed(owner.id, "owner"); const session = await signIn(stranger.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); await expectAccessDenied(await route.call()); }); diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts index 2d835d0f9..beeec56d8 100644 --- a/tests/integration/sharing-lifecycle.test.ts +++ b/tests/integration/sharing-lifecycle.test.ts @@ -20,8 +20,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -71,6 +71,11 @@ async function signIn(userId: string) { data: { userId, expiresAt: new Date(Date.now() + 60_000) }, }); cookieJar.set("healthlog_session", session.id); + // A new session is a new browser, and a new browser has adopted nothing. The + // reset matters where one test drives two of them: a stale adopted epoch + // carried across would make the second browser's first switch lose a + // compare-and-set it was never in. + adoptedEpoch = 0; return session; } @@ -147,13 +152,39 @@ async function renounce(grantId: string): Promise { ); } +/** + * Switch the way the shipped browser switches: name the epoch the client + * believes it is on, and ADOPT the context the response publishes. + * + * v1.37.0 — the adoption is not decoration. `POST /api/account/switch` and + * `GET /api/auth/me` are the only two responses a client may learn its record + * context from, and every same-origin request after a switch has to assert it + * or the record-session fence refuses. A fixture that switched and did not + * adopt would be testing the fence's pre-fence-bundle arm rather than the + * handshake this file is about — so the round trip is exercised here, which is + * the property the feature actually ships. + */ async function switchTo(accountId: string | null): Promise { const { POST } = await import("@/app/api/account/switch/route"); - return POST( - jsonRequest("http://localhost/api/account/switch", "POST", { accountId }), + const response = await POST( + jsonRequest("http://localhost/api/account/switch", "POST", { + accountId, + expectedEpoch: adoptedEpoch, + }), ); + if (response.status === 200) { + const { recordSession } = (await response.clone().json()).data; + if (recordSession) { + adoptedEpoch = recordSession.epoch; + assertRecordContext(recordSession.epoch, recordSession.scope); + } + } + return response; } +/** The epoch this fixture's browser currently believes it is on. */ +let adoptedEpoch = 0; + /** A delegable read, exactly as a migrated route builds one. */ const delegableRead: (request: NextRequest) => Promise = apiHandler( async () => { @@ -205,6 +236,8 @@ beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); + // Every test starts on a fresh browser that has adopted nothing. + adoptedEpoch = 0; }); describe("the handshake, end to end", () => { @@ -276,10 +309,7 @@ describe("the handshake, end to end", () => { // And the delegate's next request is refused on the grant, independently // of the session having been cleared. cookieJar.set("healthlog_session", delegateSession.id); - await getPrismaClient().session.update({ - where: { id: delegateSession.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(delegateSession.id, owner.id); const afterRevoke = await readRecord(); expect(afterRevoke.status).toBe(403); expect((await afterRevoke.json()).meta.errorCode).toBe( diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index fb55951df..05cfca8c1 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -42,7 +42,7 @@ import { type MutatingHandlerContract, } from "../fixtures/v137/sharing-matrix"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -140,20 +140,14 @@ async function switchInto( expect(grant.acceptedAt).not.toBeNull(); const session = await signIn(delegateId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); return { grant, session }; } /** A session inside a record nobody ever shared with this caller. */ async function claimWithoutGrant(ownerId: string, callerId: string) { const session = await signIn(callerId); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); } type Handler = ( diff --git a/tests/integration/sharing-scope-fence.test.ts b/tests/integration/sharing-scope-fence.test.ts index 1b489b8cb..17848c2fe 100644 --- a/tests/integration/sharing-scope-fence.test.ts +++ b/tests/integration/sharing-scope-fence.test.ts @@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; -import { getPrismaClient, truncateAllTables } from "./setup"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareDomain } from "@/lib/sharing/scope"; @@ -117,10 +117,7 @@ async function switchInto( }); const session = await signIn(delegateId); cookieJar.set("healthlog_session", session.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: ownerId }, - }); + await switchSessionTo(session.id, ownerId); return grant; } @@ -393,10 +390,7 @@ describe("the refusal does not distinguish itself", () => { const stranger = await makeUser("stranger"); const session = await signIn(stranger.id); cookieJar.set("healthlog_session", session.id); - await getPrismaClient().session.update({ - where: { id: session.id }, - data: { actingAsUserId: owner.id }, - }); + await switchSessionTo(session.id, owner.id); const noGrant = await PROBES.labs.call(); expect(outOfScope.status).toBe(noGrant.status);