166 lines · 6.7 KB · TypeScript
RawDownload
// 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");    });  }),);