mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
test(02-12.1): make the deferred acceptance cases prove their claims
Every one of the three observed nothing and passed anyway. - AC-04 held /api/auth/me so the record read goes first: /me is unfenced and adoptable, so the client silently re-adopted before ever sending a stale assertion - AC-06 installs its intercept before the request and holds a FENCED read; /api/auth/me is an actor surface and echoes no context at all - AC-07 moves to its own spec and a project with the service worker enabled, and reads the claim off the cache: with workers blocked the data cache is never populated and its positive control could not pass - restore the shared session after each test, and start from a known record, so these cannot fail the specs that run beside them
This commit is contained in:
1 parent
1d575cf1ae
commit
9c99ec34d6
3 files changed
+371
-90
No files matched your search
@@ -28,19 +28,33 @@ import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
|
||||
* 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.
|
||||
* 4. every response now on disk carries the TARGET's record scope and never
|
||||
* the previous one — the claim, read off the disk layer itself rather
|
||||
* than inferred from what a page did or did not paint;
|
||||
* 5. and offline, that cache is what answers a record read, still carrying
|
||||
* the target's context. The non-empty scope set is the paired positive
|
||||
* control: an empty cache would satisfy "no previous-record bytes" while
|
||||
* proving nothing.
|
||||
*/
|
||||
|
||||
const WARM_READS = [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/measurements",
|
||||
"/api/medications",
|
||||
] as const;
|
||||
/**
|
||||
* Pages whose reads the service worker is configured to hold on disk
|
||||
* (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`,
|
||||
* `/api/measurements` and `/api/medications`). Visited rather than fetched, so
|
||||
* the requests are the app's own and carry the record-session assertion.
|
||||
*/
|
||||
const WARM_PAGES = ["/", "/measurements", "/medications"] as const;
|
||||
|
||||
const TARGET_USERNAME = "e2e-scope-labs";
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
@@ -67,6 +81,30 @@ async function dataCacheState(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The record context every cached response was stored WITH.
|
||||
*
|
||||
* This is the disk-layer claim, read off the disk layer. A cached record
|
||||
* response carries the echo it was served under, so if a previous record's
|
||||
* bytes were still reachable they would show up here as that record's scope.
|
||||
*/
|
||||
async function cachedScopes(page: Page): Promise<string[]> {
|
||||
return page.evaluate(async () => {
|
||||
const scopes: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
const cached = await cache.match(request);
|
||||
const scope = cached?.headers.get("x-healthlog-record-scope");
|
||||
if (scope) scopes.push(scope);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -86,6 +124,13 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
// The session is shared with the other sharing journeys, so "not inside
|
||||
// a record" is not something a fresh page can assume.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) > 0) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Wait for the worker to control the page — until it does, nothing it
|
||||
// would cache is going through it.
|
||||
@@ -95,11 +140,24 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
{ 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);
|
||||
// Warm the disk cache by VISITING the pages, not by issuing bare
|
||||
// fetches. A bare `fetch` in `page.evaluate` carries no record-session
|
||||
// assertion, so on a session that has been inside a shared record the
|
||||
// fence refuses it and nothing cacheable comes back — the cache filled
|
||||
// from the app's own reads instead, and those were the ones that happened
|
||||
// to resolve no record scope. Driving the app is what puts real,
|
||||
// echo-carrying record responses on disk.
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, 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. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
@@ -128,25 +186,38 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`,
|
||||
);
|
||||
await expect(entry).toBeVisible();
|
||||
const targetAccountId = await entry.getAttribute("data-account-id");
|
||||
expect(targetAccountId).not.toBeNull();
|
||||
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);
|
||||
// (3) NOT asserted: "the data cache is momentarily empty".
|
||||
//
|
||||
// The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch`
|
||||
// runs before the reload — but the reload immediately refills them from
|
||||
// the target's own reads, so an empty cache is a state that exists for a
|
||||
// few milliseconds and cannot be sampled reliably. Polling for it made
|
||||
// this case fail for a reason that had nothing to do with the claim.
|
||||
//
|
||||
// The claim is asserted directly below instead, and it is the stronger
|
||||
// statement anyway: what matters is not that the cache was briefly empty
|
||||
// but that nothing in it belongs to the previous record.
|
||||
|
||||
// Fetch inside the TARGET record so there is something of its own on
|
||||
// Visit 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);
|
||||
for (const path of WARM_PAGES) {
|
||||
await page.goto(path);
|
||||
// Spelled out rather than left inside `expectShellReady`, 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. A gate hidden in a helper
|
||||
// is invisible to that guard and to the next reader.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "User menu" }),
|
||||
).toBeVisible();
|
||||
await expectShellReady(page);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await dataCacheState(page)).entries, {
|
||||
@@ -154,20 +225,59 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
})
|
||||
.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);
|
||||
// (4) + (5) The disk layer now holds the TARGET's bytes and only those,
|
||||
// read off the disk layer itself.
|
||||
//
|
||||
// The first draft asserted the shared-record banner after an offline
|
||||
// reload. That could never pass and had nothing to do with the claim:
|
||||
// `/api/auth/me` is not a cached read, so offline it fails, `useAuth`
|
||||
// errors and the shell paints its offline gate rather than a banner. The
|
||||
// banner's absence says nothing about what is on disk.
|
||||
//
|
||||
// What IS on disk is checkable, and it is the whole point: every cached
|
||||
// record response carries the echo it was served under, so a previous
|
||||
// record's bytes surviving the switch would appear here as that record's
|
||||
// scope.
|
||||
const scopes = await cachedScopes(page);
|
||||
// POSITIVE CONTROL: something really is cached with a context, so the
|
||||
// assertion below is about a non-empty set.
|
||||
expect(scopes.length).toBeGreaterThan(0);
|
||||
for (const scope of scopes) {
|
||||
expect(scope).toBe(targetAccountId);
|
||||
}
|
||||
expect(scopes).not.toContain("self");
|
||||
|
||||
// And the target's own record read really is one of the entries on disk,
|
||||
// so the scope assertion above is about the reads that matter rather
|
||||
// than about some incidental cached response.
|
||||
const cachedUrls = await page.evaluate(async () => {
|
||||
const urls: string[] = [];
|
||||
for (const name of (await caches.keys()).filter((n) =>
|
||||
n.startsWith("healthlog-data-"),
|
||||
)) {
|
||||
for (const request of await (await caches.open(name)).keys()) {
|
||||
urls.push(new URL(request.url).pathname);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
});
|
||||
expect(cachedUrls).toContain("/api/measurements");
|
||||
|
||||
// NOT asserted: that a bare `fetch` from page context can read those
|
||||
// bytes back while offline. Since v1.37.0 an echoing response also
|
||||
// carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and
|
||||
// the Cache API honours `Vary` — so a request WITHOUT the fence headers
|
||||
// does not match the stored entry at all. That is the correct behaviour
|
||||
// and a second line of defence worth naming: bytes cached under one
|
||||
// record context cannot be retrieved by a request asserting a different
|
||||
// one, or none. The app's own reads carry the headers and do match.
|
||||
} finally {
|
||||
await context.setOffline(false);
|
||||
// Hand the shared session back to its own record; see the note in
|
||||
// `v137-record-session-fence.spec.ts`.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +45,16 @@ const RECORD_READS = [
|
||||
const EPOCH_HEADER = "x-healthlog-record-epoch";
|
||||
const SCOPE_HEADER = "x-healthlog-record-scope";
|
||||
|
||||
const TARGET_USERNAME = "e2e-scope-labs";
|
||||
/**
|
||||
* A WHOLE-record READ grant, deliberately not a narrowed one.
|
||||
*
|
||||
* The narrowed `e2e-scope-labs` fixture opens only its labs section, so the
|
||||
* nav drops the other destinations and the record reads this file drives are
|
||||
* refused as out-of-scope before the fence ever matters. Section narrowing is
|
||||
* `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that
|
||||
* hides the thing under test.
|
||||
*/
|
||||
const TARGET_USERNAME = "e2e-level-read";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
@@ -66,6 +75,22 @@ function deferred() {
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave any record this session is already inside.
|
||||
*
|
||||
* These specs each begin by switching INTO a record, so they need to start
|
||||
* outside one — and the session is shared with the other sharing journeys, so
|
||||
* "outside one" is not something a fresh page can assume. Without this the
|
||||
* file passes in isolation and fails whenever it runs after a spec that left
|
||||
* the session switched, which is the least useful kind of failure.
|
||||
*/
|
||||
async function ensureOwnRecord(page: Page) {
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
if ((await banner.count()) === 0) return;
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Wait for the shell to be usable again — the paired positive control. */
|
||||
async function expectShellReady(page: Page) {
|
||||
await expect(
|
||||
@@ -94,6 +119,11 @@ 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,
|
||||
}) => {
|
||||
// Two full switches, two peer reloads each, and a poll that reloads until
|
||||
// the server write is visible to the second tab. That does not fit the
|
||||
// 30 s default, and a test that times out reports a failure that says
|
||||
// nothing about the fence.
|
||||
test.setTimeout(120_000);
|
||||
const context = await browser.newContext({
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
@@ -105,6 +135,10 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
@@ -112,12 +146,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
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();
|
||||
|
||||
// Cleared HERE, not before the click: between the click and the reload
|
||||
// the peer is still issuing reads under the context it had, and counting
|
||||
// those made the assertion below compare a pre-switch `self` against the
|
||||
// post-switch account. Only what the peer asserts once it has reconciled
|
||||
// is evidence about reconciliation.
|
||||
peerAssertions.length = 0;
|
||||
|
||||
// The peer reaches a usable shell again — the positive control, without
|
||||
// which every assertion below would pass on a wedged page.
|
||||
await peer.reload();
|
||||
@@ -139,7 +179,6 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
expect(inRecordEpoch).toBeGreaterThan(0);
|
||||
|
||||
// ── and the same on the way out ───────────────────────────────────
|
||||
peerAssertions.length = 0;
|
||||
await initiator
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click();
|
||||
@@ -147,11 +186,21 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
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);
|
||||
peerAssertions.length = 0;
|
||||
// Polled with reloads rather than reloaded once. The initiator's banner
|
||||
// disappearing means its own document navigated, not that the server
|
||||
// write has landed and become visible to another tab — asserting on one
|
||||
// reload made this leg a race against the switch-out commit.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
return peer.locator('[data-slot="shared-record-banner"]').count();
|
||||
},
|
||||
{ timeout: 40_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
expect(peerAssertions.length).toBeGreaterThan(0);
|
||||
for (const assertion of peerAssertions) {
|
||||
@@ -161,6 +210,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
expect(Number(assertion.epoch)).toBeGreaterThan(inRecordEpoch);
|
||||
}
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -180,6 +239,7 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(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
|
||||
@@ -212,6 +272,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
).toBeVisible();
|
||||
expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -228,6 +298,10 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
try {
|
||||
await Promise.all([initiator.goto("/"), peer.goto("/")]);
|
||||
await expectShellReady(peer);
|
||||
await expectShellReady(initiator);
|
||||
await ensureOwnRecord(initiator);
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
|
||||
await openSwitcher(initiator);
|
||||
const entry = switcherEntry(initiator);
|
||||
@@ -251,6 +325,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
await peer.reload();
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -262,10 +346,25 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
});
|
||||
const peer = await context.newPage();
|
||||
// Collected from the very start: the refusal can land on a poll the app
|
||||
// issues by itself, before this test navigates anywhere.
|
||||
const refusals: string[] = [];
|
||||
peer.on("response", (response) => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (response.status() !== 409) return;
|
||||
// ANY refused API read, not one of a named five. Which cells a page
|
||||
// mounts is a rendering decision that moves with the product, and a
|
||||
// hard-coded list turns "the fence refused the stale tab" into "the
|
||||
// fence refused one of the paths I happened to name" — which is how
|
||||
// this case first passed while observing nothing.
|
||||
if (!path.startsWith("/api/")) return;
|
||||
refusals.push(path);
|
||||
});
|
||||
|
||||
try {
|
||||
await peer.goto("/");
|
||||
await expectShellReady(peer);
|
||||
await ensureOwnRecord(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.
|
||||
@@ -281,19 +380,53 @@ 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. 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.goto("/medications");
|
||||
await refused;
|
||||
// The tab is now asserting a context the row has left — but observing
|
||||
// the refusal takes one deliberate step, and the reason is worth stating
|
||||
// because it is a property of the design rather than a quirk of the test.
|
||||
//
|
||||
// `GET /api/auth/me` is UNFENCED (it is the reconciliation bootstrap and
|
||||
// must answer while the context is unknown) and it is one of the two
|
||||
// responses a client may ADOPT from. So whenever the app happens to
|
||||
// re-read `/me` before it reads a record, it silently adopts the new
|
||||
// context and no stale assertion is ever sent. That is correct — and it
|
||||
// means a test that just navigates observes nothing, which is how the
|
||||
// first draft of this case passed while proving nothing.
|
||||
//
|
||||
// The window the fence exists for is the opposite ordering: a suspended
|
||||
// peer whose RECORD read goes out before any reconciliation. Holding
|
||||
// `/me` produces exactly that ordering, deterministically.
|
||||
// Held exactly once, and never unrouted: calling `unroute` while a
|
||||
// handler is still parked inside its `await` abandons that route, and
|
||||
// the `continue()` on the far side then throws "Route is already
|
||||
// handled". Letting the handler fall through after the first hold keeps
|
||||
// one owner for the route from start to finish.
|
||||
const heldMe: { release: (() => void) | null; done: boolean } = {
|
||||
release: null,
|
||||
done: false,
|
||||
};
|
||||
await peer.route("**/api/auth/me", async (route) => {
|
||||
if (heldMe.done) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
heldMe.done = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
heldMe.release = resolve;
|
||||
});
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const soft = peer.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(soft).toBeVisible();
|
||||
await soft.getByRole("link", { name: "Measurements" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => refusals.length, { timeout: 20_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Let the reconciliation through and confirm the tab recovers rather
|
||||
// than sitting on the refusal.
|
||||
heldMe.release?.();
|
||||
|
||||
// Reconciled, not evicted: the tab ends up INSIDE the record the
|
||||
// external switch put it in, with a usable shell.
|
||||
@@ -302,6 +435,16 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expectShellReady(peer);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
//
|
||||
// These fixtures' storage state is shared with the other sharing
|
||||
// journeys, and every test here deliberately moves the session's record
|
||||
// selector. Leaving it switched makes the NEXT spec fail for a reason
|
||||
// that has nothing to do with it — which is how a green file and a red
|
||||
// suite happen at the same time.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -316,66 +459,87 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
|
||||
try {
|
||||
// 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;
|
||||
let heldScope: string | null = null;
|
||||
const held: { release: (() => void) | null } = { release: null };
|
||||
let holdArmed = false;
|
||||
// page had already loaded, it matched nothing — the only request for that
|
||||
// path had come and gone, and the case passed while observing nothing.
|
||||
//
|
||||
// And it holds `/api/measurements`, a FENCED record read. The first
|
||||
// version held `/api/auth/me` because that one is alive on every boot —
|
||||
// but `/me` is an actor surface, the fence never runs on it, and it
|
||||
// therefore echoes no record context at all. Holding it captured a null
|
||||
// echo forever, which is the same "observed nothing" failure wearing a
|
||||
// different path.
|
||||
const held: {
|
||||
armed: boolean;
|
||||
release: (() => void) | null;
|
||||
epoch: string | null;
|
||||
scope: string | null;
|
||||
} = { armed: false, release: null, epoch: null, scope: null };
|
||||
|
||||
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 page.route("**/api/measurements**", async (route) => {
|
||||
if (!held.armed) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
holdArmed = false;
|
||||
held.armed = false;
|
||||
const response = await route.fetch();
|
||||
heldEcho = response.headers()["x-healthlog-record-epoch"] ?? null;
|
||||
heldScope = response.headers()["x-healthlog-record-scope"] ?? null;
|
||||
held.epoch = response.headers()["x-healthlog-record-epoch"] ?? null;
|
||||
held.scope = response.headers()["x-healthlog-record-scope"] ?? null;
|
||||
await new Promise<void>((resolve) => {
|
||||
held.release = resolve;
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expectShellReady(page);
|
||||
await ensureOwnRecord(page);
|
||||
|
||||
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({ timeout: 20_000 });
|
||||
|
||||
// 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"]')
|
||||
// Arm the hold, then make the app issue the read inside the record.
|
||||
held.armed = true;
|
||||
const sidebar = page.locator('aside[aria-label="Sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar
|
||||
.getByRole("link", { name: "Measurements" })
|
||||
.click({ noWaitAfter: true });
|
||||
|
||||
await expect
|
||||
.poll(() => held.release !== null, { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
// The response was admitted under the record's context and says so.
|
||||
// Proof the intercept saw a real fenced read rather than matching
|
||||
// nothing, which is the whole failure mode this case kept falling into.
|
||||
expect(held.epoch).toMatch(/^\d+$/);
|
||||
expect(held.scope).toBe(accountId);
|
||||
|
||||
// Now move the context out from under it, and only then let it land.
|
||||
const leaving = page
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
await page.waitForTimeout(500);
|
||||
held.release?.();
|
||||
await leaving.catch(() => {});
|
||||
|
||||
// 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.
|
||||
// The page settles on the record it is actually in — its own — without
|
||||
// painting the one the held response described.
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0, { timeout: 20_000 });
|
||||
await expectShellReady(page);
|
||||
} finally {
|
||||
// Hand the shared session back to its own record.
|
||||
await context.request
|
||||
.post("/api/account/switch", { data: { accountId: null } })
|
||||
.catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,7 +117,14 @@ 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.
|
||||
// v1.37.0 — the record-session fence specs carry the same persistent
|
||||
// server-side stamp, and more sharply: the fence's whole subject is
|
||||
// one session's record context and its monotonic epoch. Two projects
|
||||
// driving the same session concurrently would move that epoch under
|
||||
// each other, so the failure would be the harness racing itself rather
|
||||
// than anything about the fence.
|
||||
"v137-record-session-fence.spec.ts",
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
],
|
||||
use: {
|
||||
|
||||
Reference in new issue
Block a user