mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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
This commit is contained in:
1 parent
59359738c8
commit
45fec0fc1b
15 files changed
+1631
-98
No files matched your search
@@ -181,6 +181,25 @@ async function switchTo(sessionId: string, ownerId: string | null) {
|
||||
where: { id: sessionId },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
await assertCurrentContext(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — attach the record-session assertion the shipped browser client
|
||||
* attaches to every same-origin request.
|
||||
*
|
||||
* Without it every switched case in this file would be exercising the fence's
|
||||
* pre-fence-bundle arm rather than the resolver arm it was written for: a
|
||||
* session pointed at a record is fenced, and a request carrying no assertion is
|
||||
* refused with 403 before the resolver decides anything. The assertion is read
|
||||
* back off the row rather than computed, so it is truthful by construction.
|
||||
*/
|
||||
async function assertCurrentContext(sessionId: string) {
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch));
|
||||
headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self");
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -415,6 +434,12 @@ describe("acting-account resolver — the carrier column", () => {
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.actingAsUserId).toBeNull();
|
||||
|
||||
// The deletion also moved the record epoch, so a browser still asserting
|
||||
// the pre-deletion context is refused by the fence — that transition is
|
||||
// proved in `tests/integration/record-session-fence.test.ts`. What THIS
|
||||
// test is about is the column, so it reconciles first (the browser's own
|
||||
// `/api/auth/me` bootstrap) and then reads.
|
||||
await assertCurrentContext(session.id);
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(delegate.id);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user