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 | +); | |