fix(auth): a tab acts only as the account it was built for, and the review's other findings

A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
2026-10-21 01:05:12 +02:00
parent 28b77fed1e
commit 94418a087e
39 changed files with 576 additions and 78 deletions
+4 -2
View File
@@ -59,8 +59,10 @@ user), reset to a deterministic baseline every run by
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
one in-progress + one watched + one saved video, prefs pinned to `language: en`. A second account,
the plain **member** `e2e-member@siftlode.local`, gets the same baseline and its own session
(`e2e/.auth/member.json`); only `member.spec.ts` runs as it, holding what a non-admin sees (no admin
entry, no admin call, an admin link lands on the feed). Credentials come from `E2E_EMAIL` (default
(`e2e/.auth/member.json`). Two specs run as it: `member.spec.ts`, holding what a non-admin sees (no
admin entry, no admin call, no failed request, an admin link lands on the feed), and `devices.spec.ts`,
which signs it in on a desktop and an iPad at once and changes its channel layout on the server (put
back in a `finally`) — a spec that reads the member's server prefs must not run beside it. Credentials come from `E2E_EMAIL` (default
`e2e@siftlode.local`), `E2E_MEMBER_EMAIL` (default `e2e-member@siftlode.local`) and `E2E_PASSWORD`
(both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it
from the secret store. Override the container name with
+6
View File
@@ -39,6 +39,12 @@ test("a preference follows the member to another device, a device setting stays"
await gotoPage(desktop, "settings");
await desktop.getByRole("switch", { name: "Performance mode" }).click();
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1");
// The Settings prefs save as one payload: the next one (a hints change) would carry performance
// mode if it went to the account. Waited for, so the iPad below cannot simply load first.
const hints = desktop.getByRole("switch", { name: "Show hints" });
const [save] = await Promise.all([savedPrefs(desktop), hints.click()]);
expect(Object.keys(save.request().postDataJSON() as object)).not.toContain("performanceMode");
await Promise.all([savedPrefs(desktop), hints.click()]);
await openApp(ipad);
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1");
+2 -1
View File
@@ -12,7 +12,8 @@ import { retryConnect } from "./retryConnect";
* 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.
* runs as the admin; `member.spec.ts` and `devices.spec.ts` run as the plain member, from
* MEMBER_STORAGE.
*/
const STORAGE = "e2e/.auth/state.json";
+2 -1
View File
@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` runs from it. */
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */
export const MEMBER_STORAGE = "e2e/.auth/member.json";
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
+26 -7
View File
@@ -19,21 +19,39 @@ const CORE = [
"settings",
];
/** Collect every admin call and every refused response the page makes while `run` goes. */
async function watchRequests(page: Page, run: () => Promise<void>): Promise<string[]> {
const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
/** Collect every admin call and every failed API answer the page gets while `run` goes. `run` is
* handed `settled`, which waits until no API request of the page is in flight: an error state
* renders only once its request has answered, and a check made before that passes on a page that
* is still loading. */
async function watchRequests(
page: Page,
run: (settled: () => Promise<void>) => Promise<void>,
): Promise<string[]> {
const bad: string[] = [];
const pending = new Set<Request>();
const onRequest = (r: Request) => {
if (!isApi(r.url())) return;
pending.add(r);
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
};
const onDone = (r: Request) => pending.delete(r);
const onResponse = (r: Response) => {
if (r.status() === 401 || r.status() === 403) bad.push(`${r.status()} ${r.url()}`);
if (isApi(r.url()) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
};
const settled = () => expect.poll(() => pending.size, { timeout: 15_000 }).toBe(0);
page.on("request", onRequest);
page.on("requestfinished", onDone);
page.on("requestfailed", onDone);
page.on("response", onResponse);
try {
await run();
await run(settled);
await settled();
} finally {
page.off("request", onRequest);
page.off("requestfinished", onDone);
page.off("requestfailed", onDone);
page.off("response", onResponse);
}
return bad;
@@ -43,7 +61,7 @@ test.describe("a member", () => {
test("gets no admin entry, and every page of the rail opens without an error", async ({
page,
}) => {
const bad = await watchRequests(page, async () => {
const bad = await watchRequests(page, async (settled) => {
await openApp(page);
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
@@ -59,6 +77,7 @@ test.describe("a member", () => {
for (const id of ids) {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
await settled();
// An error state is a `role=alert` StateMessage; a page that failed to load shows one.
await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0);
}
@@ -71,8 +90,8 @@ test.describe("a member", () => {
const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
expect(bad, id).toEqual([]);
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
// The member lands on one of their own pages instead.
await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1);
// The member lands on the feed instead, and the rail says so.
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
}
});
});