History

test: repro sign-out answering with a raw Unauthorized page

Rhys Sullivan committed 2 months ago

Commit a5de72e1 parent 86c68af1 file · +148 −0Browse files

Unified · Split

e2e/cloud/logout-stale-session.test.ts+148−0
@@ -0,0 +1,148 @@
1+// Cloud-only: signing out must ALWAYS sign the browser out. The shell's
2+// sign-out is a top-level form POST (the WorkOS hop is cross-origin, so it
3+// can't be a fetch), which means whatever `/api/auth/logout` answers is
4+// rendered AS THE PAGE. An error response is therefore not a silent failure —
5+// it is a screenful of raw API JSON where the homepage should be. Reported
6+// from the field as `{"_tag":"Unauthorized"}`.
7+//
8+// The endpoint sits behind SessionAuth, so a browser whose sealed session no
9+// longer authenticates AT THE MOMENT OF THE CLICK gets the middleware's 401
10+// instead of being signed out. Reaching that state needs nothing exotic: a
11+// second open tab does it (sign out in one, the other's shell is still painted
12+// but its cookie is gone), and an expired or upstream-revoked session lands on
13+// the same branch. Signing out of a session that is already over is the one
14+// request that must never fail — the user is asking for the state they are
15+// already in.
16+import { expect } from "@effect/vitest";
17+import { Effect } from "effect";
18+import type { Page } from "playwright";
19+
20+import { scenario } from "../src/scenario";
21+import { Api, Browser, Target } from "../src/services";
22+
23+/** First `Set-Cookie` header for `name`, as the raw header string. */
24+const setCookieFor = (response: Response, name: string): string => {
25+ for (const header of response.headers.getSetCookie()) {
26+ if (header.startsWith(`${name}=`)) return header;
27+ }
28+ return "";
29+};
30+
31+// Open the account dropdown and sign out. Bounded retry: a click can land
32+// while the shell is still hydrating and the radix menu never opens (same
33+// idiom as auth-hint / org-switcher).
34+const clickSignOut = async (page: Page) => {
35+ for (let attempt = 1; ; attempt++) {
36+ try {
37+ await page.keyboard.press("Escape");
38+ await page.getByRole("button", { name: /Test User/ }).click();
39+ await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 });
40+ return;
41+ } catch (error) {
42+ if (attempt >= 3) throw error;
43+ }
44+ }
45+};
46+
47+scenario(
48+ "Sign-out · a browser whose session is already over is signed out, not handed an API error page",
49+ {},
50+ Effect.gen(function* () {
51+ // Gate: the REST API plane is mounted on this target.
52+ yield* Api;
53+ const target = yield* Target;
54+
55+ // Two ways a real browser arrives here holding no live session: it never
56+ // had one (the cookie was cleared, or sign-out is re-submitted from
57+ // history), and it holds one that no longer authenticates (expired, or
58+ // revoked upstream). Both POST the same form the shell posts.
59+ const signOut = (cookie?: string) =>
60+ Effect.promise(() =>
61+ fetch(new URL("/api/auth/logout", target.baseUrl), {
62+ method: "POST",
63+ redirect: "manual",
64+ ...(cookie ? { headers: { cookie } } : {}),
65+ }),
66+ );
67+
68+ for (const [label, cookie] of [
69+ ["no session cookie at all", undefined],
70+ ["a session cookie that no longer authenticates", "wos-session=stale-sealed-session"],
71+ ] as const) {
72+ const response = yield* signOut(cookie);
73+ const body = yield* Effect.promise(() => response.text());
74+
75+ // The response IS the page the user reads. This is the reported symptom.
76+ expect(body, `sign-out with ${label} never renders an API error envelope`).not.toContain(
77+ "_tag",
78+ );
79+ expect(response.status, `sign-out with ${label} sends the browser onward`).toBe(302);
80+ expect(
81+ new URL(response.headers.get("location") ?? "", target.baseUrl).pathname,
82+ `sign-out with ${label} lands the browser home`,
83+ ).toBe("/");
84+ // Nothing to end upstream without a session, but the browser must still
85+ // leave with its cookies dropped — a stale session cookie left in place
86+ // keeps routing / into the app instead of marketing.
87+ expect(
88+ setCookieFor(response, "wos-session"),
89+ `sign-out with ${label} still clears the session cookie`,
90+ ).toContain("Max-Age=0");
91+ }
92+ }),
93+);
94+
95+scenario(
96+ "Sign-out · a second open tab signs out too instead of showing raw JSON",
97+ {},
98+ Effect.gen(function* () {
99+ const browser = yield* Browser;
100+ const target = yield* Target;
101+ const identity = yield* target.newIdentity();
102+
103+ // `page` is the tab the user is LOOKING at when it breaks — the run's
104+ // video and screenshots follow it, so the artifacts show the reported
105+ // symptom rather than the tab that already signed out cleanly.
106+ yield* browser.session(identity, async ({ page, step }) => {
107+ const otherTab = await page.context().newPage();
108+
109+ await step("The user has the app open in two tabs", async () => {
110+ await page.goto("/", { waitUntil: "commit" });
111+ await page.getByRole("link", { name: "Policies" }).waitFor();
112+ await otherTab.goto("/", { waitUntil: "commit" });
113+ await otherTab.getByRole("link", { name: "Policies" }).waitFor();
114+ });
115+
116+ await step("Sign out in the other tab", async () => {
117+ await clickSignOut(otherTab);
118+ await otherTab.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", {
119+ timeout: 15_000,
120+ });
121+ });
122+
123+ // This tab was never told: its shell is still painted, and its sign-out
124+ // button still works the only way it knows how.
125+ const before = page.url();
126+ await step("Sign out in this tab, which still shows the app", async () => {
127+ await clickSignOut(page);
128+ await page.waitForURL((url) => url.toString() !== before, {
129+ timeout: 15_000,
130+ });
131+ await page.waitForLoadState("networkidle");
132+ });
133+
134+ const shown = (await page.locator("body").innerText()).trim();
135+ expect(shown, "sign-out never renders the API's error envelope as a page").not.toContain(
136+ "_tag",
137+ );
138+ expect(
139+ new URL(page.url()).pathname,
140+ "this tab lands on a signed-out page like the other one",
141+ ).toMatch(/^\/(login)?$/);
142+ expect(
143+ (await page.context().cookies()).map((cookie) => cookie.name),
144+ "the browser is left with no session either way",
145+ ).not.toContain("wos-session");
146+ });
147+ }),
148+);