Unified · Split
| @@ -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 | ); | ||