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
+7 -11
View File
@@ -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))] : [];
+8
View File
@@ -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";
}