mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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
This commit is contained in:
1 parent
45fec0fc1b
commit
3a99de312e
18 files changed
+731
-128
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -191,9 +191,33 @@ export async function getUnswitchedSession(): Promise<Awaited<
|
||||
* unswitched own-record request. The grant check happens later, inside the
|
||||
* handler, on every request.
|
||||
*/
|
||||
export async function readClaimedActingAccount(): Promise<
|
||||
string | null | undefined
|
||||
> {
|
||||
/**
|
||||
* 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<ClaimedRecordContext> {
|
||||
const header = await readSelectorHeader();
|
||||
let session: Awaited<ReturnType<typeof getSession>> = 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,
|
||||
};
|
||||
}
|
||||
+25
-3
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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<Response | null> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -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; $$",
|
||||
|
||||
@@ -24,3 +24,31 @@ export const headerJar = new Map<string, string>();
|
||||
* 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");
|
||||
}
|
||||
@@ -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<Response> = 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<Response> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
const row = await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { actingAsUserId },
|
||||
});
|
||||
assertRecordContext(row.recordEpoch, row.actingAsUserId);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Response> {
|
||||
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<Response> = 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(
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user