Files
HealthLog/src/__tests__/e2e-shared-session-guard.test.ts
T
Marc-André Bombeck 20f5ae27a0 test(e2e): give the account-switching journey its own session
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.

The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.

Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.

The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
2026-08-02 16:49:47 +02:00

96 lines
3.6 KiB
TypeScript

/**
* Structural guard on the E2E suite's session boundary.
*
* Account switching is stamped on the session row, not held in the tab. Every
* Playwright context opened from `STORAGE_STATE_PATH` carries the SAME session
* cookie, so a spec that switches that jar into another account's record
* switches it for every spec authenticating with it — and the suite runs
* `fullyParallel`. The failure lands in an unrelated file: a settings surface
* painting "Not part of shared access", an axe scan reading a banner its spec
* never asked for, a `waitForResponse` on a request the refused surface never
* sends. Nothing in the spec that fails is wrong, and nothing in its file
* explains it.
*
* So: a spec that switches accounts uses its own jar. This file holds that
* shape by matching the two things a switching spec cannot avoid naming — the
* menu entry it clicks and the endpoint that entry calls.
*
* It is a tripwire, not a proof. A future spec could switch through a control
* neither marker names, and this guard would not see it; the counter-check
* below is what stops the matchers from quietly matching nothing.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, globSync } from "node:fs";
import { join, sep } from "node:path";
const E2E = join(process.cwd(), "e2e");
/**
* Markers that mean "this spec drives an account switch": the switcher entry
* a person clicks, and the endpoint that click posts to.
*/
const SWITCH_MARKERS = ["account-switcher-entry", "/api/account/switch"];
/** The jar shared by every ordinary authenticated spec. */
const SHARED_JAR = "STORAGE_STATE_PATH";
function specFiles(): string[] {
return globSync("*.spec.ts", { cwd: E2E })
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(E2E, rel), "utf8");
}
/**
* The file with comments removed. This guard's own subject matter is worth
* explaining in prose inside the specs, and a naive substring match would be
* satisfied by that prose alone.
*/
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
function switchingSpecs(): string[] {
return specFiles().filter((rel) => {
const src = stripComments(read(rel));
return SWITCH_MARKERS.some((marker) => src.includes(marker));
});
}
/**
* `STORAGE_STATE_PATH` as a whole word, so `OWNER_STORAGE_STATE_PATH` and
* `DELEGATE_STORAGE_STATE_PATH` — the isolated jars, which are the fix rather
* than the problem — do not read as a hit.
*/
function usesSharedJar(src: string): boolean {
return new RegExp(`(^|[^A-Z_])${SHARED_JAR}\\b`).test(src);
}
describe("e2e specs that switch accounts do not share a session", () => {
it("finds the specs that switch at all", () => {
// An empty match set would make every assertion below vacuous. The suite
// has a switching journey; if it ever has none, this guard has no subject
// and should be deleted rather than left passing.
expect(switchingSpecs().length).toBeGreaterThan(0);
});
it("none of them authenticates with the shared jar", () => {
const offenders = switchingSpecs().filter((rel) =>
usesSharedJar(stripComments(read(rel))),
);
expect(offenders).toEqual([]);
});
it("the shared jar is still what the ordinary specs read", () => {
// The other way this guard could go quietly green: the shared jar renamed
// or retired, leaving the matcher above looking for a string nothing uses.
const sharing = specFiles().filter((rel) =>
usesSharedJar(stripComments(read(rel))),
);
expect(sharing.length).toBeGreaterThan(10);
});
});