Files
peter 94418a087e 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.
2026-10-21 01:05:12 +02:00

98 lines
3.8 KiB
TypeScript

import { test, expect, type Page, type Request, type Response } from "@playwright/test";
import { MEMBER_STORAGE, openApp } from "./helpers";
// What a plain member (not an admin) sees. The rest of the suite runs as the admin, so without this
// spec nothing end to end holds the member's shell: the backend refuses the admin routes (pytest),
// but a member shown an admin entry, sent to an admin page or met by a refused request would pass.
test.use({ storageState: MEMBER_STORAGE });
const ADMIN_PAGES = ["scheduler", "config", "users", "activity"];
// The pages every member has; Plex joins them only where the instance enables it.
const CORE = [
"home",
"feed",
"channels",
"playlists",
"notifications",
"files",
"stats",
"settings",
];
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 (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(settled);
await settled();
} finally {
page.off("request", onRequest);
page.off("requestfinished", onDone);
page.off("requestfailed", onDone);
page.off("response", onResponse);
}
return bad;
}
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 (settled) => {
await openApp(page);
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
const rail = page
.getByRole("navigation", { name: "Primary navigation" })
.locator('[data-testid^="nav-"]');
const ids = (await rail.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))))
.filter((id): id is string => id !== null)
.map((id) => id.replace(/^nav-/, ""));
expect(ids).toEqual(expect.arrayContaining(CORE));
expect(ids.filter((id) => ADMIN_PAGES.includes(id))).toEqual([]);
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);
}
});
expect(bad).toEqual([]);
});
test("a link to an admin page opens no admin page", async ({ page }) => {
for (const id of ADMIN_PAGES) {
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 the feed instead, and the rail says so.
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
}
});
});