Files
HealthLog/tests/integration/record-session-fence.test.ts
T
Marc-André Bombeck 45fec0fc1b feat(02-12.1): refuse a record request whose context has moved
- both record resolvers check the fence after authentication and before
  the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
  conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
2026-08-06 18:21:40 +02:00

590 lines
21 KiB
TypeScript

/**
* The record-session fence against real Postgres, real routes and the real
* resolver.
*
* ## Why a status code is not the proof
*
* "The request 409s" passes wherever the fence sits — above the grant lookup,
* below it, or after the handler has already read the record. What has to be
* proved is that a refused request did NO WORK against the record, and this
* file uses the two side effects `resolveSwitchedRecord` performs immediately
* after admission as the instrument:
*
* * `AccountGrant.lastUsedAt` is stamped by `touchGrantUsage`;
* * a day-coalesced `audit_logs` row with `action = 'sharing.record.accessed'`
* is written by `recordDelegatedAccess`.
*
* Both run only past admission, so their ABSENCE is the negative instrument and
* their PRESENCE is the positive control. They are fire-and-forget, so the
* negative assertion is immediate while the positive one polls briefly. Prisma
* query logging is not an option here: `src/lib/db.ts` builds the client with no
* `log` config, so `prisma.$on("query")` does not exist.
*
* ## The positive controls, which are half the file
*
* A fence that refused everything would pass every negative assertion in this
* plan. So three exemptions are proved explicitly rather than by absence:
* a Bearer request with no fence header is served (FENCE-AC-10), a cookie
* session at `record_epoch = 0` with no fence header is served (FENCE-AC-10),
* and a matching assertion is served with both side effects present
* (FENCE-AC-04).
*
* Break this file by deleting the `assertRecordSessionFence(auth)` line from
* `requireRecordAuth`: the stale leg fails both on the status and on
* `lastUsedAt` having moved.
*/
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";
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 { hashToken } from "@/lib/auth/hmac";
import { prisma } from "@/lib/db";
import {
RECORD_EPOCH_HEADER,
RECORD_FENCE_BOOTSTRAP,
RECORD_FENCE_ERROR_CODE,
RECORD_SCOPE_HEADER,
RECORD_SCOPE_SELF,
} from "@/lib/sharing/record-session-fence-contract";
import { pointSessionAtIfUnchanged } from "@/lib/sharing/acting-session";
import {
acceptGrant,
inviteGrant,
revokeGrant,
revokeGrantAndClearSwitch,
} from "@/lib/sharing/grants";
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `fence-${suffix}`,
email: `fence-${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 mintToken(userId: string): Promise<string> {
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId,
name: "record-session-fence-test",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
return raw;
}
async function seedWeight(userId: string, value: number) {
return getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
measuredAt: new Date(),
source: "MANUAL",
},
});
}
/**
* A delegable read, assembled from the real wrapper and the real resolver so
* what is under test is the fence rather than one domain's query shape.
*/
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
const { user, actor } = await requireRecordAuth("read", "measurements");
const rows = await prisma.measurement.findMany({
where: { userId: user.id },
orderBy: { value: "asc" },
});
return NextResponse.json({
data: {
scopeUserId: user.id,
actorUserId: actor.id,
values: rows.map((r) => r.value),
},
error: null,
});
},
);
/** A delegable WRITE, so a refused mutation can be shown to write nothing. */
const delegableWrite: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
const { user } = await requireRecordAuth("write", "measurements");
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 99,
unit: "kg",
measuredAt: new Date(),
source: "MANUAL",
},
});
return NextResponse.json({ data: { ok: true }, error: null });
},
);
function callRead(): Promise<Response> {
return delegableRead(new NextRequest("http://localhost/api/test/delegable"));
}
function callWrite(): Promise<Response> {
return delegableWrite(
new NextRequest("http://localhost/api/test/delegable", { method: "POST" }),
);
}
/** Assert a record context on the next request. */
function assertContext(epoch: number | string, scope: string | null): void {
headerJar.set(RECORD_EPOCH_HEADER, String(epoch));
headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF);
}
async function household(access: "READ" | "WRITE" = "READ") {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
await seedWeight(delegate.id, 64);
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access,
scope: null,
});
const grant = await acceptGrant({
grantId: invited.id,
granteeId: delegate.id,
});
return { owner, delegate, grant };
}
/**
* Point a session at a record OUT OF BAND — the "raw or external switch" of
* acceptance case 04. No client journal runs, no BroadcastChannel fires; the
* selector simply moves and the epoch follows it.
*/
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 grantLastUsedAt(grantId: string): Promise<Date | null> {
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grantId },
});
return row.lastUsedAt;
}
async function delegatedAccessRows(ownerId: string, actorId: string) {
return getPrismaClient().auditLog.findMany({
where: {
userId: ownerId,
actorUserId: actorId,
action: "sharing.record.accessed",
},
});
}
/** The two admission side effects are fire-and-forget; give them a moment. */
async function waitFor(
predicate: () => Promise<boolean>,
label: string,
): Promise<void> {
for (let i = 0; i < 40; i++) {
if (await predicate()) return;
await new Promise((r) => setTimeout(r, 25));
}
throw new Error(`timed out waiting for ${label}`);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("FENCE-AC-04 a stale context is refused before any record work", () => {
it("FENCE-AC-04 refuses a delegated read whose epoch is behind, touching neither the grant nor the record", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
// The tab formed its intent one transition ago.
assertContext(epoch - 1, owner.id);
const res = await callRead();
const body = await res.json();
// The instrument comes FIRST, deliberately. Both writes happen only past
// admission, so their absence is the proof that no grant was looked up and
// no record was opened — where a status code alone would pass with the
// fence sitting anywhere, including after the handler had read the record.
// Asserting the status first would mask this one behind it whenever the
// fence is removed, which is exactly the case this file exists to catch.
// Both writes are fire-and-forget, so the assertion is immediate on
// purpose: a fence placed after the grant lookup would already have
// stamped them by the time the response was built.
expect(await grantLastUsedAt(grant.id)).toBeNull();
expect(await delegatedAccessRows(owner.id, delegate.id)).toHaveLength(0);
expect(res.status).toBe(409);
expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
});
it("FENCE-AC-04 refuses a stale delegated WRITE and writes nothing to either record", async () => {
const { owner, delegate, grant } = await household("WRITE");
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch - 1, owner.id);
const res = await callWrite();
expect(res.status).toBe(409);
expect(await grantLastUsedAt(grant.id)).toBeNull();
// One seeded row each, and no third row anywhere.
const all = await getPrismaClient().measurement.findMany();
expect(all).toHaveLength(2);
expect(all.some((m) => m.value === 99)).toBe(false);
});
it("FENCE-AC-04 refuses a matching epoch whose SCOPE moved", async () => {
// Belt and braces: the epoch alone distinguishes transitions, the scope
// catches a selector that moved to a value the client did not expect.
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch, "some-other-account");
const res = await callRead();
expect(res.status).toBe(409);
expect(await grantLastUsedAt(grant.id)).toBeNull();
});
it("FENCE-AC-04 POSITIVE CONTROL: a matching assertion is served and both side effects land", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch, owner.id);
const res = await callRead();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.scopeUserId).toBe(owner.id);
expect(body.data.actorUserId).toBe(delegate.id);
// The owner's 81 kg row and not the delegate's 64 kg one. An empty list
// would satisfy a weaker assertion while hiding the failure that matters.
expect(body.data.values).toEqual([81]);
// The response echoes the context it was served under.
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe(String(epoch));
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(owner.id);
await waitFor(
async () => (await grantLastUsedAt(grant.id)) !== null,
"AccountGrant.lastUsedAt",
);
await waitFor(
async () => (await delegatedAccessRows(owner.id, delegate.id)).length > 0,
"sharing.record.accessed audit row",
);
});
});
describe("FENCE-AC-10 the exemptions, proved rather than assumed", () => {
it("FENCE-AC-10 serves a Bearer request with no fence header at all", async () => {
const { owner, delegate } = await household();
const raw = await mintToken(delegate.id);
cookieJar.clear();
headerJar.set("authorization", `Bearer ${raw}`);
headerJar.set("x-healthlog-account", owner.id);
const res = await callRead();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.scopeUserId).toBe(owner.id);
// Nothing is echoed on this transport either: the native contract neither
// sends nor receives the fence headers.
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBeNull();
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBeNull();
});
it("FENCE-AC-10 serves a never-switched cookie session with no fence header at all", async () => {
const delegate = await makeUser("solo");
await seedWeight(delegate.id, 70);
await signIn(delegate.id);
const res = await callRead();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.values).toEqual([70]);
// The exemption still echoes what it served under, so a fence-aware client
// has something to validate against from its very first request.
expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe("0");
expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
});
it("FENCE-AC-10 serves a never-switched session that sends the bootstrap sentinel", async () => {
const delegate = await makeUser("booting");
await seedWeight(delegate.id, 71);
await signIn(delegate.id);
headerJar.set(RECORD_EPOCH_HEADER, RECORD_FENCE_BOOTSTRAP);
headerJar.set(RECORD_SCOPE_HEADER, RECORD_FENCE_BOOTSTRAP);
const res = await callRead();
expect(res.status).toBe(200);
});
it("FENCE-AC-10 refuses a HEADERLESS request on a fenced session with the code a pre-fence bundle recovers from", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
await switchOutOfBand(session.id, owner.id);
// No fence headers at all — a bundle that predates the fence.
const res = await callRead();
const body = await res.json();
expect(res.status).toBe(403);
// The code the deployed bundle's grant-loss bridge already acts on: it
// leaves the record and hard-navigates, which serves the fence-aware
// bundle. Handing it the 409 instead would strand it.
expect(body.meta.errorCode).toBe("sharing.access.denied");
expect(await grantLastUsedAt(grant.id)).toBeNull();
});
it("FENCE-AC-10 keeps a session fenced after it has LEFT a record", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchOutOfBand(session.id, owner.id);
const epoch = await switchOutOfBand(session.id, null);
expect(epoch).toBe(2);
// A tab still believing it is inside the owner's record.
assertContext(1, owner.id);
expect((await callRead()).status).toBe(409);
// And the same session, asserting the truth, is served its own record.
assertContext(2, null);
const ok = await callRead();
expect(ok.status).toBe(200);
expect((await ok.json()).data.values).toEqual([64]);
});
});
describe("FENCE-AC-08 concurrent switchers resolve to one monotonic outcome", () => {
it("FENCE-AC-08 lets exactly one of two same-epoch switches land", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
// Start INSIDE a record, so both competing targets are genuinely distinct
// from the current selector. Two tabs racing to a value the row already
// holds is not a race at all — the trigger declines to move on a no-op
// write and both callers are correctly told their intent stands, which is
// the arm asserted separately below.
const start = await switchOutOfBand(session.id, owner.id);
expect(start).toBe(1);
// A real second account: the selector carries a foreign key, so a race
// between two invented ids would fail on the constraint rather than on the
// compare-and-set.
const otherOwner = await makeUser("owner-b");
const [first, second] = await Promise.all([
pointSessionAtIfUnchanged(session.id, delegate.id, null, start),
pointSessionAtIfUnchanged(session.id, delegate.id, otherOwner.id, start),
]);
const outcomes = [first.kind, second.kind].sort();
expect(outcomes).toEqual(["applied", "stale"]);
const applied = first.kind === "applied" ? first : second;
expect(applied.kind).toBe("applied");
if (applied.kind === "applied") {
// The epoch handed back is the one the trigger committed, read in the
// same statement that committed it.
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: session.id },
});
expect(applied.epoch).toBe(row.recordEpoch);
expect(applied.epoch).toBe(start + 1);
}
});
it("FENCE-AC-08 treats a switch to where the session already is as applied, and does not move the epoch", async () => {
// The trigger declines a no-op write (`IS DISTINCT FROM`), so the epoch
// stands. The caller is still told `applied`, and that is the honest
// answer: they asked for the session to be pointed somewhere and it is.
// Reporting `stale` here would make a client reconcile against a context
// that never changed.
const { delegate } = await household();
const session = await signIn(delegate.id);
const outcome = await pointSessionAtIfUnchanged(
session.id,
delegate.id,
null,
0,
);
expect(outcome).toEqual({ kind: "applied", epoch: 0 });
});
it("FENCE-AC-08 refuses a switch that names an epoch the row has already left", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
const start = 0;
const landed = await pointSessionAtIfUnchanged(
session.id,
delegate.id,
owner.id,
start,
);
expect(landed.kind).toBe("applied");
// A second tab still holding the pre-switch epoch.
const stale = await pointSessionAtIfUnchanged(
session.id,
delegate.id,
null,
start,
);
expect(stale.kind).toBe("stale");
// And the row is where the winner put it, not where the loser wanted it.
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: session.id },
});
expect(row.actingAsUserId).toBe(owner.id);
});
it("FENCE-AC-08 refuses a switch aimed at another user's session", async () => {
// The CAS does not weaken the ownership pin: a matching epoch on somebody
// else's session still matches no row.
const { owner, delegate } = await household();
const stranger = await makeUser("stranger");
const session = await signIn(delegate.id);
const outcome = await pointSessionAtIfUnchanged(
session.id,
stranger.id,
owner.id,
0,
);
expect(outcome.kind).toBe("stale");
});
});
describe("FENCE-AC-09 the fence refuses after every lifecycle transition", () => {
it("FENCE-AC-09 refuses the pre-revocation context after a revoke", async () => {
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch, owner.id);
expect((await callRead()).status).toBe(200);
// The verb the sharing panel calls. It clears the selector inside the same
// transaction that stamps the row, so the epoch moves with it and the tab's
// context is stale from the very next request.
await revokeGrantAndClearSwitch({
grantId: grant.id,
grantorId: owner.id,
});
const res = await callRead();
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
});
it("FENCE-AC-09 still refuses on the grant when a revocation leaves the selector in place", async () => {
// `revokeGrant` is the bare state transition and clears no session. The
// fence is not a substitute for the grant check and must not become one:
// the context is still provable, so the fence passes, and the resolver
// refuses on the grant exactly as it did before the fence existed.
const { owner, delegate, grant } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch, owner.id);
expect((await callRead()).status).toBe(200);
await revokeGrant({ grantId: grant.id, grantorId: owner.id });
const res = await callRead();
expect(res.status).toBe(403);
expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
});
it("FENCE-AC-09 refuses the pre-deletion context after the owner account is deleted", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
const epoch = await switchOutOfBand(session.id, owner.id);
assertContext(epoch, owner.id);
expect((await callRead()).status).toBe(200);
// The referential action, which runs no application code. The epoch moves
// anyway, because the trigger owns it.
await getPrismaClient().user.delete({ where: { id: owner.id } });
expect((await callRead()).status).toBe(409);
});
});