// Cloud-only: signing out must ALWAYS sign the browser out. The shell's // sign-out is a top-level form POST (the WorkOS hop is cross-origin, so it // can't be a fetch), which means whatever `/api/auth/logout` answers is // rendered AS THE PAGE. An error response is therefore not a silent failure — // it is a screenful of raw API JSON where the homepage should be. Reported // from the field as `{"_tag":"Unauthorized"}`. // // The endpoint used to sit behind SessionAuth, so a browser whose sealed // session no longer authenticated AT THE MOMENT OF THE CLICK got the // middleware's 401 instead of being signed out. Reaching that state needs // nothing exotic: a second open tab does it (sign out in one, the other's // shell is still painted but its cookie is gone), and an expired or // upstream-revoked session lands on the same branch. Signing out of a session // that is already over is the one request that must never fail — the user is // asking for the state they are already in. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { Page } from "playwright"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import { settle } from "../src/surfaces/browser"; /** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ const HINT_COOKIE = "executor-auth-hint"; /** First `Set-Cookie` header for `name`, as the raw header string. */ const setCookieFor = (response: Response, name: string): string => { for (const header of response.headers.getSetCookie()) { if (header.startsWith(`${name}=`)) return header; } return ""; }; // Open the account dropdown and sign out. Bounded retry: a click can land // while the shell is still hydrating and the radix menu never opens (same // idiom as auth-hint / org-switcher). const clickSignOut = async (page: Page) => { for (let attempt = 1; ; attempt++) { try { await page.keyboard.press("Escape"); await page.getByRole("button", { name: /Test User/ }).click(); await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 }); return; } catch (error) { if (attempt >= 3) throw error; } } }; scenario( "Sign-out · a browser whose session is already over is signed out, not handed an API error page", {}, Effect.gen(function* () { // Gate: the REST API plane is mounted on this target. yield* Api; const target = yield* Target; const signOut = (cookie?: string) => Effect.promise(() => fetch(new URL("/api/auth/logout", target.baseUrl), { method: "POST", redirect: "manual", ...(cookie ? { headers: { cookie } } : {}), }), ); // A session cookie that no longer authenticates — expired, or revoked // upstream while the tab sat open. There is nothing to end at WorkOS, but // this browser must still leave signed out: a stale session cookie left in // place keeps routing / into the app instead of marketing. const stale = yield* signOut("wos-session=stale-sealed-session"); const staleBody = yield* Effect.promise(() => stale.text()); // The response IS the page the user reads. This is the reported symptom. expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag"); expect(stale.status, "sign-out sends the browser onward").toBe(302); expect( new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname, "sign-out lands the browser home", ).toBe("/"); expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain( "Max-Age=0", ); expect( setCookieFor(stale, HINT_COOKIE), "the auth hint never outlives the session it describes", ).toContain("Max-Age=0"); // A POST carrying no cookies at all is what a CROSS-SITE form reaching // this endpoint looks like: both cookies are SameSite=Lax, so another // origin's POST arrives bare. Signing out is public, so it must answer // such a request without touching this browser's session — otherwise any // site could sign our users out. const bare = yield* signOut(); const bareBody = yield* Effect.promise(() => bare.text()); expect(bareBody, "a session-less sign-out renders no error envelope either").not.toContain( "_tag", ); expect(bare.status, "it is sent home like any other sign-out").toBe(302); expect( new URL(bare.headers.get("location") ?? "", target.baseUrl).pathname, "it lands home", ).toBe("/"); expect( bare.headers.getSetCookie(), "a request that presented no session cannot expire anyone else's", ).toEqual([]); }), ); scenario( "Sign-out · a second open tab signs out too instead of showing raw JSON", {}, Effect.gen(function* () { const browser = yield* Browser; const target = yield* Target; const identity = yield* target.newIdentity(); // `page` is the tab the user is LOOKING at when it breaks — the run's // video and screenshots follow it, so the artifacts show the reported // symptom rather than the tab that already signed out cleanly. yield* browser.session(identity, async ({ page, step }) => { const otherTab = await page.context().newPage(); await step("The user has the app open in two tabs", async () => { await page.goto("/", { waitUntil: "commit" }); await page.getByRole("link", { name: "Policies" }).waitFor(); await otherTab.goto("/", { waitUntil: "commit" }); await otherTab.getByRole("link", { name: "Policies" }).waitFor(); }); await step("Sign out in the other tab", async () => { await clickSignOut(otherTab); await otherTab.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", { timeout: 15_000, }); }); // This tab was never told: its shell is still painted, and its sign-out // button still works the only way it knows how. const before = page.url(); await step("Sign out in this tab, which still shows the app", async () => { await clickSignOut(page); await page.waitForURL((url) => url.toString() !== before, { timeout: 15_000, }); await settle(page); }); const shown = (await page.locator("body").innerText()).trim(); expect(shown, "sign-out never renders the API's error envelope as a page").not.toContain( "_tag", ); expect( new URL(page.url()).pathname, "this tab lands on a signed-out page like the other one", ).toMatch(/^\/(login)?$/); expect( (await page.context().cookies()).map((cookie) => cookie.name), "the browser is left with no session either way", ).not.toContain("wos-session"); }); }), );