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.
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18import type { Page } from "playwright";
19
20import { scenario } from "../src/scenario";
21import { Api, Browser, Target } from "../src/services";
22
23/** First `Set-Cookie` header for `name`, as the raw header string. */
24const 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).
34const 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
47scenario(
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
95scenario(
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);