test(e2e): a plain member's shell is held end to end, and a page it cannot open lands on the feed

The suite ran only as an admin, so nothing end to end held what a member sees. A second seeded
account, a member with its own session, now walks every rail page (no admin entry, no admin call,
no error state) and follows links to the admin pages.

It found one: a member's link to an admin page showed the feed with no rail row current, and the
address bar kept naming the admin page — only the content fell back. The navigation state now
derives the page on screen through each module's gate (reachablePage), so the rail, the content,
the tab title and the address bar agree, on a link, a Back, and a role taken away mid-session.
This commit is contained in:
2026-10-20 04:08:11 +02:00
parent 909732b3c1
commit dc4f4f5143
11 changed files with 351 additions and 190 deletions
+49 -31
View File
@@ -1,20 +1,23 @@
import { test as setup, expect } from "@playwright/test";
import { test as setup, expect, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE } from "./helpers";
import { retryConnect } from "./retryConnect";
/**
* One-time setup for the characterization net (the `setup` project the tests depend on):
* 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`);
* 2. seed — reset the dedicated E2E account to a known baseline, in-container (idempotent);
* 3. authenticate ONCE via password-login and persist the session + `en` locale to
* storageState, so the real specs never re-auth (auth endpoints are rate-limited).
* 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent);
* 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own
* storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite
* runs as the admin; `member.spec.ts` runs as the plain member, from MEMBER_STORAGE.
*/
const STORAGE = "e2e/.auth/state.json";
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
const MEMBER_EMAIL = process.env.E2E_MEMBER_EMAIL || "e2e-member@siftlode.local";
// No default: a password committed to a public repo is not a credential. The ops wrapper
// (`siftlode e2e` / `check-full`) exports it from the secret store.
const PASSWORD = process.env.E2E_PASSWORD ?? "";
@@ -27,14 +30,43 @@ const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
// The in-container seed only makes sense when the target is the local stack.
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
setup("seed + authenticate", async ({ page }) => {
/** Sign `email` in on `page`'s context and save that context as `path`. */
async function signIn(page: Page, email: string, path: string): Promise<void> {
// These requests run in Node and share the page's cookie jar — so the `session` cookie the login
// receives is what storageState saves below — and a localhost connect the machine dropped is
// tried again (retryConnect).
const api = retryConnect(page.request);
const login = await api.post("/auth/password-login", { data: { email, password: PASSWORD } });
expect(
login.ok(),
`password-login failed for ${email} — is the E2E account seeded?`,
).toBeTruthy();
const me = await (await api.get("/api/me")).json();
expect(me.authenticated, `session not established after login (${email})`).toBeTruthy();
// Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay that
// would intercept every click — the test accounts have no YouTube connection, so it auto-opens),
// and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one
// Settings → Appearance → Startup writes. Both flags are per-account:
// `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
}, me.id);
mkdirSync(dirname(path), { recursive: true });
await page.context().storageState({ path });
}
setup("seed + authenticate", async ({ page, browser }) => {
expect(
PASSWORD,
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
).not.toBe("");
// These requests run in Node (sharing the page's cookie jar), so a localhost connect the machine
// dropped is tried again (retryConnect).
const api = retryConnect(page.request);
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
@@ -55,6 +87,8 @@ setup("seed + authenticate", async ({ page }) => {
"-e",
`E2E_EMAIL=${EMAIL}`,
"-e",
`E2E_MEMBER_EMAIL=${MEMBER_EMAIL}`,
"-e",
`E2E_PASSWORD=${PASSWORD}`,
CONTAINER,
"python",
@@ -69,28 +103,12 @@ setup("seed + authenticate", async ({ page }) => {
}
}
// 3. Authenticate. page.request shares the browser context's cookie jar, so the `session`
// cookie it receives is captured by storageState below.
const login = await api.post("/auth/password-login", {
data: { email: EMAIL, password: PASSWORD },
});
expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy();
const me = await (await api.get("/api/me")).json();
expect(me.authenticated, "session not established after login").toBeTruthy();
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
// that would intercept every click — the test account has no YouTube connection, so it
// auto-opens), and opt the account out of starting on Home. A bare URL lands on
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
}, me.id);
mkdirSync(dirname(STORAGE), { recursive: true });
await page.context().storageState({ path: STORAGE });
// 3. Authenticate each account in its own context: one cookie jar holds one session.
await signIn(page, EMAIL, STORAGE);
const memberContext = await browser.newContext({ storageState: { cookies: [], origins: [] } });
try {
await signIn(await memberContext.newPage(), MEMBER_EMAIL, MEMBER_STORAGE);
} finally {
await memberContext.close();
}
});