mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(02-12.1): prove the record-session fence end to end
- give every acceptance case a stable id and assert the mapping from a runnable artifact, recording which cases execute in the ordinary gate and which await the browser stage - add the browser spec for the cases no cooperative layer can reach: peers reconciling to an exact context, an aborted switch response, a closed initiator, a delayed response, and the offline disk layers
This commit is contained in:
1 parent
31b4857d8b
commit
8fcbdcc0df
4 files changed
+653
-4
No files matched your search
@@ -0,0 +1,449 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
|
||||
/**
|
||||
* The record-session fence, in a real browser.
|
||||
*
|
||||
* `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer:
|
||||
* a peer tab holds while the initiator's switch is in flight, because the
|
||||
* initiator told it to. This file proves the cases that layer cannot reach —
|
||||
* the ones where nobody tells anybody anything:
|
||||
*
|
||||
* * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely
|
||||
* to "something changed";
|
||||
* * FENCE-AC-02 the initiator's response never arrives, because the tab is
|
||||
* torn off mid-flight;
|
||||
* * FENCE-AC-03 the initiator is CLOSED after the server committed, so no
|
||||
* commit broadcast is ever published;
|
||||
* * FENCE-AC-06 a record response delayed across the switch is discarded by
|
||||
* the client rather than painted;
|
||||
* * FENCE-AC-07 the disk layers — CacheStorage and the service worker's
|
||||
* `healthlog-data-*` — cannot serve one record's bytes inside another.
|
||||
*
|
||||
* ## Why every absence assertion here is paired
|
||||
*
|
||||
* "The peer never showed the owner's data" passes on a page that is
|
||||
* permanently wedged, which is a worse outcome than the bug. So each of those
|
||||
* legs additionally asserts the peer REACHES a usable state on the target
|
||||
* scope once `/me` resolves. An absence-only assertion in this file would be a
|
||||
* check that cannot fail.
|
||||
*
|
||||
* Uses the existing synthetic `e2e/setup` fixtures; introduces no new account
|
||||
* anchors.
|
||||
*/
|
||||
|
||||
const RECORD_READS = [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/insights/targets",
|
||||
"/api/medications",
|
||||
"/api/medications/compliance",
|
||||
"/api/labs",
|
||||
] as const;
|
||||
|
||||
const EPOCH_HEADER = "x-healthlog-record-epoch";
|
||||
const SCOPE_HEADER = "x-healthlog-record-scope";
|
||||
|
||||
const TARGET_USERNAME = "e2e-scope-labs";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
}
|
||||
|
||||
function switcherEntry(page: Page, username = TARGET_USERNAME) {
|
||||
return page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
/** Wait for the shell to be usable again — the paired positive control. */
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "User menu" })).toBeVisible();
|
||||
}
|
||||
|
||||
/** The record context a page asserted on its most recent record read. */
|
||||
function collectAssertions(
|
||||
page: Page,
|
||||
into: { epoch: string; scope: string }[],
|
||||
) {
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (!(RECORD_READS as readonly string[]).includes(path)) return;
|
||||
const headers = request.headers();
|
||||
const epoch = headers[EPOCH_HEADER];
|
||||
const scope = headers[SCOPE_HEADER];
|
||||
if (epoch === undefined || scope === undefined) return;
|
||||
into.push({ epoch, scope });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
peerAssertions.length = 0;
|
||||
await entry.click();
|
||||
await expect(
|
||||
initiator.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The peer reaches a usable shell again — the positive control, without
|
||||
// which every assertion below would pass on a wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// And every record read it now issues names the record it is actually
|
||||
// in, not "something". A peer that reconciled to a guess would show up
|
||||
// here as a scope naming the wrong account or a stale epoch.
|
||||
expect(peerAssertions.length).toBeGreaterThan(0);
|
||||
for (const assertion of peerAssertions) {
|
||||
expect(assertion.scope).toBe(accountId);
|
||||
expect(assertion.epoch).toMatch(/^\d+$/);
|
||||
expect(assertion.epoch).not.toBe("bootstrap");
|
||||
}
|
||||
const inRecordEpoch = Number(peerAssertions[0].epoch);
|
||||
expect(inRecordEpoch).toBeGreaterThan(0);
|
||||
|
||||
// ── and the same on the way out ───────────────────────────────────
|
||||
peerAssertions.length = 0;
|
||||
await initiator
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click();
|
||||
await expect(
|
||||
initiator.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
expect(peerAssertions.length).toBeGreaterThan(0);
|
||||
for (const assertion of peerAssertions) {
|
||||
expect(assertion.scope).toBe("self");
|
||||
// Leaving moved the epoch too: a tab believing it is still inside the
|
||||
// record is the same defect as the reverse.
|
||||
expect(Number(assertion.epoch)).toBeGreaterThan(inRecordEpoch);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
const peerAssertions: { epoch: string; scope: string }[] = [];
|
||||
collectAssertions(peer, peerAssertions);
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
|
||||
// Drive the switch server-side and abort the response on the way back:
|
||||
// the write lands, the initiator never learns it did, and no commit
|
||||
// broadcast is ever published.
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(500);
|
||||
|
||||
// The peer must never paint owner-ready state it guessed at …
|
||||
const guessed = peerAssertions.filter(
|
||||
(a) => a.scope === accountId && a.epoch === "bootstrap",
|
||||
);
|
||||
expect(guessed).toEqual([]);
|
||||
|
||||
// … and must still REACH ready on the target scope once `/me` answers.
|
||||
// Without this the leg above would pass on a permanently wedged page.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const initiator = await context.newPage();
|
||||
const peer = await context.newPage();
|
||||
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
await expect(entry).toBeVisible();
|
||||
|
||||
// Hold the response, close the tab while it is in flight. The server has
|
||||
// committed; nothing will ever broadcast the commit.
|
||||
const held = deferred();
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
await held.promise;
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(300);
|
||||
held.release();
|
||||
await initiator.close();
|
||||
|
||||
// The surviving tab is not permanently gated: `/me` is the bootstrap and
|
||||
// it answers regardless of who began the transition.
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const peer = await context.newPage();
|
||||
|
||||
try {
|
||||
await peer.goto("/");
|
||||
await expectShellReady(peer);
|
||||
|
||||
// A switch driven from a request context that runs no client journal at
|
||||
// all — the "raw or external" case the cooperative layer cannot see.
|
||||
await openSwitcher(peer);
|
||||
const entry = switcherEntry(peer);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
await peer.keyboard.press("Escape");
|
||||
|
||||
const response = await context.request.post("/api/account/switch", {
|
||||
data: { accountId },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// The tab is now asserting a context the row has left. Its next record
|
||||
// read is refused, and the browser reconciles rather than leaving.
|
||||
const refused = peer.waitForResponse(
|
||||
(r) =>
|
||||
(RECORD_READS as readonly string[]).includes(
|
||||
new URL(r.url()).pathname,
|
||||
) && r.status() === 409,
|
||||
);
|
||||
await peer.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await refused;
|
||||
|
||||
// Reconciled, not evicted: the tab ends up INSIDE the record the
|
||||
// external switch put it in.
|
||||
await expect(
|
||||
peer.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
|
||||
// Hold one record read open across the switch. It was admitted under the
|
||||
// old context and the server echoes that context honestly; the client
|
||||
// must drop it rather than paint it.
|
||||
const held = deferred();
|
||||
let heldEcho: string | null = null;
|
||||
await page.route("**/api/medications", async (route) => {
|
||||
const response = await route.fetch();
|
||||
heldEcho = response.headers()[EPOCH_HEADER] ?? null;
|
||||
await held.promise;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await openSwitcher(page);
|
||||
const entry = switcherEntry(page);
|
||||
await expect(entry).toBeVisible();
|
||||
await entry.click();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
held.release();
|
||||
// The stale response resolved. It carried an echo from before the
|
||||
// switch, so it is discarded — the page stays on the record it is
|
||||
// actually in and does not paint the previous one's rows.
|
||||
await page.waitForTimeout(300);
|
||||
expect(heldEcho).not.toBeNull();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
// The gate is spelled out here rather than left inside `expectShellReady`
|
||||
// because this test's next act is a one-shot `page.evaluate`, and
|
||||
// `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
|
||||
// text between the navigation and the read. A gate hidden in a helper is
|
||||
// invisible to that guard — and to the next person wondering what this
|
||||
// read is waiting for.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
// Warm the cached record reads the service worker is configured to hold.
|
||||
for (const path of [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/measurements",
|
||||
"/api/medications",
|
||||
]) {
|
||||
await page.evaluate(async (p) => {
|
||||
await fetch(p).catch(() => {});
|
||||
}, path);
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// POSITIVE CONTROL, first: the owner snapshot really WAS on disk before
|
||||
// the switch. A test that seeded nothing proves nothing.
|
||||
const seeded = await page.evaluate(async () => {
|
||||
const names = await caches.keys();
|
||||
const data = names.filter((n) => n.startsWith("healthlog-data-"));
|
||||
let entries = 0;
|
||||
for (const name of data) {
|
||||
entries += (await (await caches.open(name)).keys()).length;
|
||||
}
|
||||
return { names, dataCaches: data, entries };
|
||||
});
|
||||
expect(seeded.dataCaches.length).toBeGreaterThan(0);
|
||||
expect(seeded.entries).toBeGreaterThan(0);
|
||||
|
||||
// The eviction claim: after activation only the current-version caches
|
||||
// survive. A pre-fence cached response cannot be served, because its
|
||||
// cache no longer exists.
|
||||
const stale = seeded.names.filter(
|
||||
(n) =>
|
||||
/^healthlog-(static|pages|data)-/.test(n) &&
|
||||
!seeded.dataCaches.includes(n) &&
|
||||
!n.includes(seeded.dataCaches[0].replace("healthlog-data-", "")),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
|
||||
await openSwitcher(page);
|
||||
const entry = switcherEntry(page);
|
||||
await expect(entry).toBeVisible();
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
await entry.click();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// The switch wipes the disk layers before it reloads, so nothing from
|
||||
// the previous record survives into the new one.
|
||||
const afterSwitch = await page.evaluate(async () => {
|
||||
const names = (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
);
|
||||
let entries = 0;
|
||||
for (const name of names) {
|
||||
entries += (await (await caches.open(name)).keys()).length;
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
expect(afterSwitch).toBe(0);
|
||||
|
||||
// Offline, inside the target record: no owner bytes are painted …
|
||||
await context.setOffline(true);
|
||||
await page.reload().catch(() => {});
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// … and the paired positive control — the target's own offline data IS
|
||||
// reachable once it has been fetched inside this record.
|
||||
await context.setOffline(false);
|
||||
await page.reload();
|
||||
await expectShellReady(page);
|
||||
expect(accountId).not.toBeNull();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The ten acceptance cases of the record-session fence, mapped to the files
|
||||
* that prove them — as a runnable artifact rather than a table in a document.
|
||||
*
|
||||
* A dropped case is the failure this exists to catch. A test deleted during a
|
||||
* refactor takes its coverage with it silently; a document claiming ten cases
|
||||
* keeps claiming ten. So every case carries a stable id, the id appears in the
|
||||
* TITLE of at least one `it(` / `test(` in the file named below, and this file
|
||||
* asserts the mapping.
|
||||
*
|
||||
* ## What this guard cannot do, said plainly
|
||||
*
|
||||
* It proves an id appears in a test title. It does NOT prove that test asserts
|
||||
* anything worth asserting — that is what the named instruments are for, and
|
||||
* each case below records which instrument carries its weight. A guard that
|
||||
* only counted titles would be satisfiable by ten empty tests, so the
|
||||
* per-case `instrument` field is a review anchor, not a claim this file
|
||||
* verifies.
|
||||
*
|
||||
* ## Executed here versus deferred
|
||||
*
|
||||
* Five cases are proved by Vitest files that run in the ordinary gate. Five
|
||||
* ride `e2e/v137-record-session-fence.spec.ts` and therefore run only in the
|
||||
* Playwright acceptance stage. That split is recorded in `runner` below and
|
||||
* asserted, so nobody can read a green run of THIS file as "all ten proved" —
|
||||
* it means "all ten are claimed, and here is which ones have actually
|
||||
* executed".
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface AcceptanceCase {
|
||||
id: string;
|
||||
/** What the handoff asked for. */
|
||||
case: string;
|
||||
/** Where it lives, relative to the repository root. */
|
||||
home: string;
|
||||
/** Vitest runs in the ordinary gate; Playwright runs in the acceptance stage. */
|
||||
runner: "vitest" | "playwright";
|
||||
/** The thing that makes the assertion falsifiable. A review anchor. */
|
||||
instrument: string;
|
||||
}
|
||||
|
||||
const CASES: AcceptanceCase[] = [
|
||||
{
|
||||
id: "FENCE-AC-01",
|
||||
case: "Switch in and out; peers reconcile to the exact new epoch and scope",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Every record read the peer issues afterwards is inspected for the asserted epoch and scope; the epoch must have moved and the scope must name the record, not merely differ. Paired with a shell-ready assertion so a wedged page cannot pass.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-02",
|
||||
case: "Server switch driven externally, initiator response aborted",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Peers never assert a guessed owner scope under the bootstrap sentinel, AND do reach ready on the target scope once /me resolves. The second half is mandatory: an absence-only assertion passes on a permanently wedged page.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-03",
|
||||
case: "Initiator closed after the server commit, before any commit broadcast",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"The surviving tab reaches ready rather than sitting behind a permanent hydration gate — /me is the bootstrap and answers regardless of who began the transition.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-04",
|
||||
case: "Raw or external switch; a stale peer read and mutation refused before handler or database work",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"AccountGrant.lastUsedAt unchanged and no audit_logs row with action = 'sharing.record.accessed' for the pair — both are written only past admission, so their absence is the no-database-work proof. Asserted BEFORE the status code so a removed fence fails on the instrument rather than being masked. Paired with a positive control that moves both. Also the findFirst zero-call spy in src/lib/__tests__/acting-account-resolver.test.ts, plus an e2e leg driving the switch from a request context that runs no client journal.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-05",
|
||||
case: "Owner idempotent mutation and replay, switch before handler authorization",
|
||||
home: "tests/integration/record-session-fence-idempotency.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The seeded owner cell is byte-unchanged — same id, responseStatus, responseBody, expiresAt, createdAt. expiresAt carries the weight: completion rewrites it to now+24h and a fresh claim to now+2min, so any wrapper that reached the cache moves it. The 'no row exists' assertion was rejected as unfalsifiable, because releaseClaim deletes the pending row whenever the handler throws.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-06",
|
||||
case: "Record response delayed across the switch; the client discards and holds",
|
||||
home: "src/lib/api/__tests__/record-fence-client.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"A response whose echoed epoch is one behind the adopted one makes the caller throw and fires the hold exactly once, while a response carrying NO echo is returned untouched and fires nothing. The second half is the version-poller case and is what stops the rule from being discard-on-absence. An e2e leg holds a real record read open across a real switch.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-07",
|
||||
case: "IndexedDB and CacheStorage seeded with an owner snapshot, switch in, go offline",
|
||||
home: "e2e/v137-record-session-fence.spec.ts",
|
||||
runner: "playwright",
|
||||
instrument:
|
||||
"Positive control first — the owner snapshot really was in CacheStorage before the switch, asserted by a non-zero entry count. Then the eviction claim of the cache-version policy, then a zero entry count after the switch, then offline with the target's own data reachable.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-08",
|
||||
case: "Concurrent switchers; monotonic compare-and-set ordering, a stale switch cannot override",
|
||||
home: "tests/integration/record-session-fence.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Two same-epoch switches to genuinely distinct targets resolve to exactly one 'applied' and one 'stale', and the epoch handed back to the winner equals the row's committed value. A separate leg pins the no-op case, where the trigger declines to move and 'applied' is the honest answer.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-09",
|
||||
case: "Revoke, account deletion, managed-profile lifecycle, FK-driven clearing",
|
||||
home: "tests/integration/record-session-epoch-trigger.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"The ON DELETE SET NULL referential action and a raw SQL clear both bump the epoch with no application code running, while the per-request last_active_at and expires_at writes do not. A lifecycle leg in tests/integration/record-session-fence.test.ts then asserts the fence refuses the pre-transition context after a revoke and after an owner deletion.",
|
||||
},
|
||||
{
|
||||
id: "FENCE-AC-10",
|
||||
case: "Pre-fence cookie callers fail closed into their reload; token and native keep the frozen contract",
|
||||
home: "src/__tests__/record-session-fence-compat.test.ts",
|
||||
runner: "vitest",
|
||||
instrument:
|
||||
"Every link in the pre-fence tab's only recovery chain is frozen, and the 409 is asserted ABSENT from the grant-loss predicate. The fence's own positive controls live in tests/integration/record-session-fence.test.ts: a Bearer request with no fence header is served, and an epoch-zero cookie request with no fence header is served. Without those two a fence that refused everything would pass every negative test in this plan.",
|
||||
},
|
||||
];
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(ROOT, rel), "utf8");
|
||||
}
|
||||
|
||||
/** Every `it(` / `test(` title in a file. */
|
||||
function testTitles(source: string): string[] {
|
||||
const titles: string[] = [];
|
||||
for (const m of source.matchAll(
|
||||
/\b(?:it|test)(?:\.\w+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g,
|
||||
)) {
|
||||
titles.push(m[2]);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
describe("the ten acceptance cases are all claimed by a runnable test", () => {
|
||||
it("declares exactly ten distinct ids", () => {
|
||||
expect(CASES).toHaveLength(10);
|
||||
expect(new Set(CASES.map((c) => c.id)).size).toBe(10);
|
||||
for (const c of CASES) {
|
||||
expect(c.id).toMatch(/^FENCE-AC-\d{2}$/);
|
||||
// A review anchor with nothing in it is not one.
|
||||
expect(c.instrument.length).toBeGreaterThan(80);
|
||||
expect(c.case.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(CASES)("$id appears in a test title in $home", (c) => {
|
||||
const titles = testTitles(read(c.home));
|
||||
// Non-zero proof: a matcher that found no titles at all would make the
|
||||
// membership check below vacuous.
|
||||
expect(titles.length).toBeGreaterThan(0);
|
||||
const carrying = titles.filter((t) => t.includes(c.id));
|
||||
expect(carrying.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("records which cases have actually executed in this gate", () => {
|
||||
// Five cases ride Playwright and therefore prove nothing until the
|
||||
// acceptance stage runs. Stating the split here is what stops a green run
|
||||
// of this file from being read as "all ten proved".
|
||||
const vitest = CASES.filter((c) => c.runner === "vitest").map((c) => c.id);
|
||||
const playwright = CASES.filter((c) => c.runner === "playwright").map(
|
||||
(c) => c.id,
|
||||
);
|
||||
|
||||
expect(vitest).toEqual([
|
||||
"FENCE-AC-04",
|
||||
"FENCE-AC-05",
|
||||
"FENCE-AC-06",
|
||||
"FENCE-AC-08",
|
||||
"FENCE-AC-09",
|
||||
"FENCE-AC-10",
|
||||
]);
|
||||
expect(playwright).toEqual([
|
||||
"FENCE-AC-01",
|
||||
"FENCE-AC-02",
|
||||
"FENCE-AC-03",
|
||||
"FENCE-AC-07",
|
||||
]);
|
||||
expect(vitest.length + playwright.length).toBe(10);
|
||||
});
|
||||
|
||||
it("names only files that exist and carry real tests", () => {
|
||||
for (const home of new Set(CASES.map((c) => c.home))) {
|
||||
const source = read(home);
|
||||
expect(source.length).toBeGreaterThan(500);
|
||||
expect(testTitles(source).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe("what the browser asserts on a request", () => {
|
||||
});
|
||||
|
||||
describe("what the browser does with an echoed context", () => {
|
||||
it("serves a response carrying no echo, and enters no hold", () => {
|
||||
it("FENCE-AC-06 serves a response carrying no echo, and enters no hold", () => {
|
||||
// The `/api/version` case, named because it is the concrete thing a
|
||||
// discard-on-absence rule breaks: it is polled on a timer.
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
@@ -137,7 +137,7 @@ describe("what the browser does with an echoed context", () => {
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a response delayed across a switch, and enters the hold", () => {
|
||||
it("FENCE-AC-06 discards a response delayed across a switch, and enters the hold", () => {
|
||||
// Acceptance case 6: the response was already past the fence when the
|
||||
// switch committed, so the server served it honestly under the old context
|
||||
// and said so. The browser is on epoch 5 now.
|
||||
|
||||
@@ -199,8 +199,9 @@ describe("FENCE-AC-05 an idempotent write under a moved record context", () => {
|
||||
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.
|
||||
// rewrote it. `expiresAt` is the falsifiable half: completion rewrites it
|
||||
// to now+24h and a fresh claim to now+2min, so a wrapper that reached the
|
||||
// cache at all would move it.
|
||||
const after = await cells();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].id).toBe(before.id);
|
||||
|
||||
Reference in new issue
Block a user