docs(02-12.1): narrow two overstated claims

- the idempotency arm refuses only a wrongly-asserted context; an admin
  route behind unfenced requireAdmin still serves a headerless request
- the epoch trigger does not fire on a statement that names record_epoch
  without moving the selector; the source guard is what covers that
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 20:48:50 +02:00
1 parent 731203bdf4
commit dc343f434c
7 files changed
+289 -136

No files matched your search

@@ -0,0 +1,174 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
/**
* FENCE-AC-07 — the disk layers cannot serve one record's bytes inside
* another.
*
* ## Why this is a spec of its own
*
* `playwright.config.ts` sets `serviceWorkers: "block"` for every other
* project, and for a good reason written there: a worker-originated `fetch` is
* not subject to `page.route`, so the SW would bypass the per-spec route mocks.
*
* This case is the exact inverse. Its whole claim is about what the
* `healthlog-data-*` cache can and cannot serve across a record switch — and
* with the worker blocked that cache is never populated at all, so the positive
* control ("the owner snapshot really WAS on disk before the switch") could
* never pass no matter what the application did. A check that cannot pass is
* worse than no check, so this runs in the `chromium-service-worker` project
* with the worker allowed, and uses no route mocks.
*
* ## What is asserted, in order
*
* 1. the owner's record reads really are on disk before the switch — a test
* that seeded nothing proves nothing;
* 2. only current-version caches exist, which is the eviction the fence's
* design relies on to make "a response with no echo" safe;
* 3. the switch empties the data cache before it reloads;
* 4. offline inside the target record, no previous-record bytes are painted;
* 5. and — the paired positive control — the target's own data IS painted
* once it has been fetched inside this record, so 4 cannot pass on a
* blank page.
*/
const WARM_READS = [
"/api/dashboard/snapshot",
"/api/measurements",
"/api/medications",
] as const;
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();
}
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();
}
/** What the `healthlog-data-*` caches hold right now. */
async function dataCacheState(page: Page) {
return 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 };
});
}
test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
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 a helper because
// the 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.
await expect(
page.getByRole("button", { name: "User menu" }),
).toBeVisible();
await expectShellReady(page);
// Wait for the worker to control the page — until it does, nothing it
// would cache is going through it.
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
// Warm the reads the SW is configured to hold on disk.
for (const path of WARM_READS) {
await page.evaluate(async (p) => {
await fetch(p).catch(() => {});
}, path);
}
await expect
.poll(async () => (await dataCacheState(page)).entries, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// (1) POSITIVE CONTROL: the owner's bytes really are on disk.
const seeded = await dataCacheState(page);
expect(seeded.dataCaches.length).toBeGreaterThan(0);
expect(seeded.entries).toBeGreaterThan(0);
// (2) Only current-version caches survive activation. This is the
// eviction the fence's "a response with no echo is served normally" rule
// depends on: a PRE-fence cached response carries no echo, and it can
// only be served if its cache is still there.
const version = seeded.dataCaches[0].replace("healthlog-data-", "");
const stale = seeded.names.filter(
(n) =>
/^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version),
);
expect(stale).toEqual([]);
await openSwitcher(page);
const entry = page.locator(
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
);
await expect(entry).toBeVisible();
await entry.click();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// (3) The switch wipes the disk layers before it reloads, so nothing
// from the previous record survives into the new one.
await expect
.poll(async () => (await dataCacheState(page)).entries, {
timeout: 15_000,
})
.toBe(0);
// Fetch inside the TARGET record so there is something of its own on
// disk to find.
for (const path of WARM_READS) {
await page.evaluate(async (p) => {
await fetch(p).catch(() => {});
}, path);
}
await expect
.poll(async () => (await dataCacheState(page)).entries, {
timeout: 15_000,
})
.toBeGreaterThan(0);
// (4) + (5) Offline, inside the target record: the shell still paints
// and it still says which record it is in. The banner is the paired
// positive control — an offline page that rendered nothing at all would
// satisfy "no previous-record bytes" while proving nothing.
await context.setOffline(true);
await page.reload().catch(() => {});
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toContainText(TARGET_USERNAME);
} finally {
await context.setOffline(false);
await context.close();
}
});
});
+51 -118
View File
@@ -273,6 +273,7 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
const entry = switcherEntry(peer);
await expect(entry).toBeVisible();
const accountId = await entry.getAttribute("data-account-id");
expect(accountId).not.toBeNull();
await peer.keyboard.press("Escape");
const response = await context.request.post("/api/account/switch", {
@@ -280,22 +281,25 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
});
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.
// The tab is now asserting a context the row has left. Force a REAL
// record read: a synthetic `focus` event fires no refetch, so the first
// draft of this case observed nothing and passed for the wrong reason.
// A client-side navigation mounts a page whose cells fetch.
const refused = peer.waitForResponse(
(r) =>
(RECORD_READS as readonly string[]).includes(
new URL(r.url()).pathname,
) && r.status() === 409,
{ timeout: 20_000 },
);
await peer.evaluate(() => window.dispatchEvent(new Event("focus")));
await peer.goto("/medications");
await refused;
// Reconciled, not evicted: the tab ends up INSIDE the record the
// external switch put it in.
// external switch put it in, with a usable shell.
await expect(
peer.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
).toBeVisible({ timeout: 20_000 });
await expectShellReady(peer);
} finally {
await context.close();
@@ -311,137 +315,66 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
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();
// The intercept goes on BEFORE the first navigation. Installed after the
// page had already loaded, it matched nothing — the only request for
// that path had come and gone, and the case passed while observing
// nothing at all.
let heldEcho: string | null = null;
await page.route("**/api/medications", async (route) => {
let heldScope: string | null = null;
const held: { release: (() => void) | null } = { release: null };
let holdArmed = false;
await page.route("**/api/auth/me", async (route) => {
// `/api/auth/me` is the one read EVERY record keeps alive, on every
// boot, regardless of which sections a narrowed grant opens — the
// previous choice was dormant under the target's grant, so it never
// resolved across the switch.
if (!holdArmed) {
await route.continue();
return;
}
holdArmed = false;
const response = await route.fetch();
heldEcho = response.headers()[EPOCH_HEADER] ?? null;
await held.promise;
heldEcho = response.headers()["x-healthlog-record-epoch"] ?? null;
heldScope = response.headers()["x-healthlog-record-scope"] ?? null;
await new Promise<void>((resolve) => {
held.release = resolve;
});
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();
).toBeVisible({ timeout: 20_000 });
// 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);
// Now arm the hold and make the app issue the read again, so a response
// is genuinely in flight while the context moves back.
holdArmed = true;
const leaving = page
.locator('[data-slot="shared-record-banner-exit"]')
.click({ noWaitAfter: true });
// 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();
await expect
.poll(() => held.release !== null, { timeout: 20_000 })
.toBe(true);
held.release?.();
await leaving.catch(() => {});
// … 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();
// The held response carried a real echo — proof the intercept saw the
// request rather than matching nothing.
expect(heldEcho).not.toBeNull();
expect(heldScope).not.toBeNull();
// And the page settles on the record it is actually in, without painting
// the one the held response described.
await expectShellReady(page);
expect(accountId).not.toBeNull();
} finally {
await context.close();
}
+29
View File
@@ -75,12 +75,39 @@ export default defineConfig({
projects: [
{
name: "chromium-desktop",
// The disk-layer spec needs a live service worker, which the shared
// `use` block blocks for every other spec. It runs in its own project.
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
},
},
{
// v1.37.0 — the one project that lets the service worker run.
//
// Every other project blocks it, and the reason is written in the `use`
// block above: a worker-originated `fetch` is not subject to
// `page.route`, so the SW would bypass the per-spec route mocks. The
// record-session fence's disk-layer case is the exact inverse — its whole
// claim is about what the `healthlog-data-*` cache can and cannot serve
// across a switch, and with the worker blocked that cache is never
// populated, so its positive control could never pass. A check that
// cannot pass is worse than no check, so the spec gets a project rather
// than a caveat.
//
// It uses no route mocks for exactly this reason, and it is the only
// spec in this project.
name: "chromium-service-worker",
testMatch: ["v137-record-session-fence-offline.spec.ts"],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
serviceWorkers: "allow",
},
},
{
name: "chromium-mobile",
// The scoped-sharing journeys each carry a persistent server-side
@@ -90,6 +117,8 @@ export default defineConfig({
testIgnore: [
"v137-sharing-managed-profiles.spec.ts",
"v137-sharing-managed-profiles-a11y.spec.ts",
// Runs only in the service-worker project below.
"v137-record-session-fence-offline.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
@@ -25,10 +25,19 @@
-- silently, and a fence whose counter can silently fail to move is a fence that
-- passes stale requests. A row trigger cannot be forgotten.
--
-- BEFORE, not AFTER, and that timing is load-bearing: the trigger overwrites
-- whatever `record_epoch` the incoming statement supplied, so no writer can set
-- the epoch itself even by trying. The counter belongs to the database in the
-- strong sense.
-- BEFORE, not AFTER, and that timing is load-bearing: on any statement that
-- moves the selector, the trigger overwrites whatever `record_epoch` the
-- incoming statement supplied, so a writer cannot steer the counter while
-- switching.
--
-- What that does NOT cover, stated because the first draft of this comment
-- overstated it: a statement that names `record_epoch` and does NOT change
-- `acting_as_user_id` never fires this trigger at all, so it writes the column
-- freely. The trigger owns the counter on the path that matters — every
-- selector move — and what keeps application code away from the column
-- otherwise is the source guard in
-- `src/__tests__/record-session-fence-guard.test.ts`, which holds the set of
-- files naming it to one.
--
-- `UPDATE OF "acting_as_user_id"` plus the `WHEN` clause keep the two writes
-- that touch a session on nearly every request off the trigger entirely — the
@@ -96,10 +96,10 @@ const CASES: AcceptanceCase[] = [
{
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",
home: "e2e/v137-record-session-fence-offline.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.",
"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 record still named. It lives in its own spec and its own Playwright project because every other project sets serviceWorkers: 'block', under which the data cache is never populated and the positive control could not pass whatever the application did.",
},
{
id: "FENCE-AC-08",
@@ -89,12 +89,15 @@ const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
/**
* The helpers that deliberately do NOT consult the fence.
*
* This is a statement about the HELPERS and must not be read as "no admin route
* is ever fenced". `POST /api/admin/backups/[id]/restore` composes
* `withIdempotency` OUTSIDE `apiHandler`, so it is fence-visible through the
* idempotency arm even though `requireAdmin` itself is on this list — and that
* is wanted, not an oversight. An idempotent admin write under an unprovable
* context should be refused like any other.
* This is a statement about the HELPERS. It must not be read as "no admin route
* is ever fenced", and it must not be read as the reverse either.
* `POST /api/admin/backups/[id]/restore` composes `withIdempotency` OUTSIDE
* `apiHandler`, so the wrapper's fence check runs on it — but that check
* refuses only the STALE half. An `unfenced-client` verdict falls through to
* the handler, whose `requireAdmin` is on this list, so a headerless admin
* request is served exactly as it was before the fence existed. The accurate
* claim is therefore narrow: an idempotent admin write under a context the
* client asserted WRONGLY is refused; one that asserts nothing is not.
*
* Each entry's reason, once:
*
@@ -38,12 +38,17 @@
* the client's context is unknown — it is where the client LEARNS the
* context it would otherwise have to assert.
*
* That list is about the auth HELPERS, not about routes, and it is not
* absolute: `POST /api/admin/backups/[id]/restore` composes the idempotency
* wrapper OUTSIDE `apiHandler`, so it is fence-visible through the idempotency
* arm even though `requireAdmin` itself is unfenced. That is wanted. An
* idempotent admin write under an unprovable context should be refused like any
* other.
* That list is about the auth HELPERS, not about routes, and it is not quite
* absolute — but the exception is narrower than it first looks and the exact
* shape matters. `POST /api/admin/backups/[id]/restore` composes the
* idempotency wrapper OUTSIDE `apiHandler`, so the wrapper's own fence check
* does run on it. That check refuses only the STALE half: an `unfenced-client`
* verdict falls through to the handler, and the handler's `requireAdmin` is
* unfenced, so a headerless admin request is served exactly as before. So the
* accurate claim is "an idempotent admin write under a context the client
* asserted WRONGLY is refused", not "admin routes are fenced". A pre-fence
* admin tab keeps working, which is the deploy-compatibility posture
* everywhere else too.
*
* ─── Why there are two refusals and not one ───────────────────────────────
*