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:
@@ -57,9 +57,13 @@ admin so the suite can reach the admin-only pages / render branches, matching th
|
||||
user), reset to a deterministic baseline every run by
|
||||
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
|
||||
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`. Credentials come
|
||||
from `E2E_EMAIL` (default `e2e@siftlode.local`) and `E2E_PASSWORD` — **required, no default**: run
|
||||
the suite through the ops wrapper, which exports it from the secret store. Override the container name with
|
||||
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@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
|
||||
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
|
||||
|
||||
## Selectors
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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. */
|
||||
export const MEMBER_STORAGE = "e2e/.auth/member.json";
|
||||
|
||||
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
|
||||
export async function openApp(page: Page, path = "/"): Promise<void> {
|
||||
await page.goto(path);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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",
|
||||
];
|
||||
|
||||
/** 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 bad: string[] = [];
|
||||
const onRequest = (r: Request) => {
|
||||
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
|
||||
};
|
||||
const onResponse = (r: Response) => {
|
||||
if (r.status() === 401 || r.status() === 403) bad.push(`${r.status()} ${r.url()}`);
|
||||
};
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
page.off("request", onRequest);
|
||||
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 () => {
|
||||
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");
|
||||
// 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 one of their own pages instead.
|
||||
await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||
// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine
|
||||
// answers for all, so the other projects leave it out instead of tripling its time.
|
||||
const CHROMIUM_ONLY = /a11y\.spec\.ts/;
|
||||
// The axe scan reads the DOM and the accessibility tree, which the engines build alike, and the
|
||||
// member spec checks which pages and requests a role gets, which no engine changes: one engine
|
||||
// answers for all, so the other projects leave them out instead of tripling their time.
|
||||
const CHROMIUM_ONLY = /(a11y|member)\.spec\.ts/;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
|
||||
+7
-11
@@ -7,7 +7,6 @@ import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
import {
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
@@ -22,7 +21,7 @@ import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
||||
import { moduleOrder } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
import { LoadingState } from "./components/ui/QueryState";
|
||||
@@ -373,14 +372,11 @@ export default function App() {
|
||||
? `yt:${ytSearch}`
|
||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
||||
|
||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A
|
||||
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
|
||||
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
||||
// side rail shows behind it.
|
||||
const pageGate = moduleDef(page).gate;
|
||||
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
||||
// the account can't reach never gets here: the navigation state already moved it to the feed
|
||||
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[page];
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex text-fg">
|
||||
@@ -444,7 +440,7 @@ export default function App() {
|
||||
</PageShell>
|
||||
) : (
|
||||
<PageShell
|
||||
title={t(pageTitleKey(activePage))}
|
||||
title={t(pageTitleKey(page))}
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||
|
||||
@@ -47,7 +47,10 @@ function mount() {
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
||||
// On a Plex-enabled instance: the remembered page these cases restore is the Plex module, which only
|
||||
// an instance with Plex lets an account open.
|
||||
const account = (over: Partial<Me> = {}) =>
|
||||
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
|
||||
let savePrefs: MockInstance<typeof api.savePrefs>;
|
||||
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ async function openOverlay() {
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1 });
|
||||
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
|
||||
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
@@ -295,6 +296,43 @@ describe("NavigationProvider · Back", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · a page the account cannot open", () => {
|
||||
const member = { id: 1, role: "user", plex_enabled: true };
|
||||
|
||||
// A member following a link to an admin page saw the feed with no rail row current: the page
|
||||
// stayed "scheduler" while only the content fell back.
|
||||
it("lands on the feed, and the address bar says so", () => {
|
||||
qc.setQueryData(qk.me(), member);
|
||||
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("feed");
|
||||
expect(window.location.search).toBe("?keep=1");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||
});
|
||||
|
||||
it("lands on the feed when Back reaches an entry naming one", () => {
|
||||
qc.setQueryData(qk.me(), member);
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("files"));
|
||||
act(() => {
|
||||
window.history.replaceState({ sfPage: "config" }, "", "/?page=config");
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "config" } }));
|
||||
});
|
||||
expect(result.current.page).toBe("feed");
|
||||
});
|
||||
|
||||
it("leaves the page it is on once the account loses it", async () => {
|
||||
window.history.replaceState(null, "", "/?page=users");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("users");
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), member);
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.page).toBe("feed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
||||
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
|
||||
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
} from "../lib/history";
|
||||
import { reachablePage } from "../lib/modules";
|
||||
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
||||
import {
|
||||
NavigationActionsContext,
|
||||
@@ -31,7 +32,12 @@ import {
|
||||
|
||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
const [chosenPage, setPageState] = useState<Page>(loadInitialPage);
|
||||
// The page on screen: the chosen one when this account may open it, else the feed — derived, so a
|
||||
// link, a Back and a role taken away mid-session all land the same way, and the rail, the content
|
||||
// and the address bar (stamped from it below) agree.
|
||||
const me = useMe();
|
||||
const page = reachablePage(chosenPage, me);
|
||||
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
||||
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
||||
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
||||
@@ -171,7 +177,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
|
||||
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
|
||||
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
|
||||
const signedIn = useMe() != null;
|
||||
const signedIn = me != null;
|
||||
const objectTitle = useDocumentObjectTitle();
|
||||
useEffect(() => {
|
||||
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
||||
|
||||
@@ -183,3 +183,11 @@ export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map
|
||||
export function moduleOrder(me: Me): Page[] {
|
||||
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
||||
}
|
||||
|
||||
/** The page this account is shown for `page`: itself when its gate lets the account in, else the
|
||||
* feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed
|
||||
* out, no page renders, so nothing is moved. */
|
||||
export function reachablePage(page: Page, me: Me | null | undefined): Page {
|
||||
const gate = moduleDef(page).gate;
|
||||
return !me || !gate || gate(me) ? page : "feed";
|
||||
}
|
||||
|
||||
+31
-25
@@ -4,14 +4,15 @@ Run INSIDE the api container — it needs the app's models + the argon2 hasher:
|
||||
|
||||
docker exec -i siftlode-api-1 python - < scripts/e2e_seed.py
|
||||
|
||||
Resets a dedicated test account to a known baseline every run, so the Playwright
|
||||
golden-master net is deterministic and never touches a real account. Self-healing: a crashed
|
||||
Resets two dedicated test accounts to a known baseline every run, so the Playwright
|
||||
golden-master net is deterministic and never touches a real account: the admin the suite runs as,
|
||||
and a plain member (`member.spec.ts`) that holds what a non-admin sees. Self-healing: a crashed
|
||||
spec that left the account dirty is cleaned on the next run. Prints one JSON line describing
|
||||
what it seeded (the wrapper / global-setup reads it).
|
||||
|
||||
Credentials come from env: E2E_EMAIL (default e2e@siftlode.local) and E2E_PASSWORD (REQUIRED, no
|
||||
default — the ops wrapper exports it from the secret store); the account
|
||||
lives only in the localdev DB.
|
||||
Credentials come from env: E2E_EMAIL (default e2e@siftlode.local), E2E_MEMBER_EMAIL (default
|
||||
e2e-member@siftlode.local) and E2E_PASSWORD (REQUIRED, no default — the ops wrapper exports it from
|
||||
the secret store; both accounts share it); the accounts live only in the localdev DB.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -31,6 +32,7 @@ from app.reader.models import Playlist, PlaylistItem, VideoState
|
||||
from app.security import hash_password
|
||||
|
||||
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
|
||||
MEMBER_EMAIL = os.environ.get("E2E_MEMBER_EMAIL", "e2e-member@siftlode.local")
|
||||
PASSWORD = os.environ.get("E2E_PASSWORD", "")
|
||||
if not PASSWORD:
|
||||
sys.exit(
|
||||
@@ -42,6 +44,18 @@ N_SUBS = 3
|
||||
def main() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_mark_configured(db)
|
||||
# Admin so the E2E can reach the admin-only pages (scheduler / config / users / audit) and
|
||||
# thus the render ternary's admin branches — the primary real user (npeter83) is admin too.
|
||||
admin = _seed_account(db, EMAIL, "admin", "E2E Tester")
|
||||
member = _seed_account(db, MEMBER_EMAIL, "user", "E2E Member")
|
||||
# T201: this script's stdout IS its interface — the e2e runner parses this JSON.
|
||||
print(json.dumps({"ok": True, **admin, "member": member})) # noqa: T201
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _mark_configured(db) -> None:
|
||||
# 0. Mark the instance configured so the first-run setup wizard is skipped. A prod→dev
|
||||
# reseed (siftlode seed-dev-from-prod) TRUNCATEs app_state — it's excluded from the
|
||||
# data allow-list, being local/operational state — which would otherwise drop the box
|
||||
@@ -57,21 +71,21 @@ def main() -> None:
|
||||
app_state.setup_token_hash = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_account(db, email: str, role: str, display_name: str) -> dict:
|
||||
# 1. Upsert the account and always reset it to a clean, loginable baseline
|
||||
# (email_verified + is_active + not suspended + password set).
|
||||
user = db.scalar(select(User).where(User.email == EMAIL))
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is None:
|
||||
user = User(email=EMAIL)
|
||||
user = User(email=email)
|
||||
db.add(user)
|
||||
user.password_hash = hash_password(PASSWORD)
|
||||
user.email_verified = True
|
||||
user.is_active = True
|
||||
user.is_suspended = False
|
||||
user.google_sub = None
|
||||
# Admin so the E2E can reach the admin-only pages (scheduler / config / users / audit) and
|
||||
# thus the render ternary's admin branches — the primary real user (npeter83) is admin too.
|
||||
user.role = "admin"
|
||||
user.display_name = "E2E Tester"
|
||||
user.role = role
|
||||
user.display_name = display_name
|
||||
user.preferences = {"language": "en"}
|
||||
db.commit()
|
||||
|
||||
@@ -91,7 +105,9 @@ def main() -> None:
|
||||
for cid in channel_ids:
|
||||
db.add(
|
||||
Subscription(
|
||||
user_id=user.id, channel_id=cid, subscribed_at=datetime.now(timezone.utc)
|
||||
user_id=user.id,
|
||||
channel_id=cid,
|
||||
subscribed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
@@ -111,9 +127,7 @@ def main() -> None:
|
||||
]
|
||||
if exclude:
|
||||
conds.append(Video.id.not_in(exclude))
|
||||
return db.scalar(
|
||||
select(Video).where(*conds).order_by(Video.published_at.desc()).limit(1)
|
||||
)
|
||||
return db.scalar(select(Video).where(*conds).order_by(Video.published_at.desc()).limit(1))
|
||||
|
||||
used: set[str] = set()
|
||||
in_progress = None
|
||||
@@ -164,22 +178,14 @@ def main() -> None:
|
||||
saved = svid.id
|
||||
db.commit()
|
||||
|
||||
# T201: this script's stdout IS its interface — the e2e runner parses this JSON.
|
||||
print( # noqa: T201
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"email": EMAIL,
|
||||
"email": email,
|
||||
"channels": channel_ids,
|
||||
"in_progress": in_progress,
|
||||
"watched": watched,
|
||||
"saved": saved,
|
||||
}
|
||||
)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user