|
| 1 | +// Cloud-only (browser): a bare entry (`/`) lands in the org this browser LAST |
| 2 | +// worked in, not the org pinned into the session cookie at login time. |
| 3 | +// |
| 4 | +// Org switching is stateless (the URL slug scopes every request; nothing |
| 5 | +// rewrites the session cookie), so without extra memory a bare entry would |
| 6 | +// always canonicalize onto the session's login-time org — for a multi-org user |
| 7 | +// that reads as "executor.sh forgot which org I was on". The last-org cookie |
| 8 | +// (`executor-last-org`) is that memory: the client records the slug of the org |
| 9 | +// it is verifiably viewing, and the SSR gate redirects bare document requests |
| 10 | +// onto it after re-checking live membership. |
| 11 | +import { expect } from "@effect/vitest"; |
| 12 | +import { Effect } from "effect"; |
| 13 | + |
| 14 | +import { scenario } from "../src/scenario"; |
| 15 | +import { Browser, Target } from "../src/services"; |
| 16 | + |
| 17 | +const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); |
| 18 | + |
| 19 | +scenario( |
| 20 | + "Org URLs · a bare entry lands in the last-visited org, not the session's login org", |
| 21 | + {}, |
| 22 | + Effect.gen(function* () { |
| 23 | + const target = yield* Target; |
| 24 | + const browser = yield* Browser; |
| 25 | + |
| 26 | + // Identity starts in org A. Create org B through the real endpoint, which |
| 27 | + // returns the refreshed cookie — the SESSION is now pinned to org B, so |
| 28 | + // without the last-org cookie every bare entry would land in B. |
| 29 | + const identity = yield* target.newIdentity(); |
| 30 | + const cookie = identity.headers?.cookie ?? ""; |
| 31 | + |
| 32 | + const createB = yield* Effect.promise(() => |
| 33 | + fetch(new URL("/api/auth/create-organization", target.baseUrl), { |
| 34 | + method: "POST", |
| 35 | + headers: { |
| 36 | + "content-type": "application/json", |
| 37 | + cookie, |
| 38 | + ...CLOUD_ORIGIN_HEADERS(target.baseUrl), |
| 39 | + }, |
| 40 | + body: JSON.stringify({ name: "Last Visited Org B" }), |
| 41 | + }), |
| 42 | + ); |
| 43 | + expect(createB.ok, "org B was created").toBe(true); |
| 44 | + const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string }; |
| 45 | + const setCookie = createB.headers.get("set-cookie") ?? ""; |
| 46 | + const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1]; |
| 47 | + expect(sessionB, "creating org B pinned the session into it").toBeTruthy(); |
| 48 | + |
| 49 | + const orgs = (yield* Effect.promise(() => |
| 50 | + fetch(new URL("/api/auth/organizations", target.baseUrl), { |
| 51 | + headers: { cookie: `wos-session=${sessionB}` }, |
| 52 | + }).then((r) => r.json()), |
| 53 | + )) as { organizations: ReadonlyArray<{ name: string; slug: string }> }; |
| 54 | + const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug; |
| 55 | + expect(slugA, "org A has a slug").toBeTruthy(); |
| 56 | + expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug); |
| 57 | + |
| 58 | + // Drive the browser as the session pinned to B. |
| 59 | + const inB = { |
| 60 | + ...identity, |
| 61 | + headers: { cookie: `wos-session=${sessionB}` }, |
| 62 | + cookies: [{ name: "wos-session", value: sessionB! }], |
| 63 | + }; |
| 64 | + |
| 65 | + yield* browser.session(inB, async ({ page, step }) => { |
| 66 | + await step("Work in org A by its slug URL (the session still pins org B)", async () => { |
| 67 | + await page.goto(`/${slugA}`, { waitUntil: "networkidle" }); |
| 68 | + await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 69 | + // The client records the viewed org once /account/me confirms it. |
| 70 | + await page.waitForFunction( |
| 71 | + (slug) => document.cookie.includes(`executor-last-org=${slug}`), |
| 72 | + slugA, |
| 73 | + { timeout: 30_000 }, |
| 74 | + ); |
| 75 | + }); |
| 76 | + |
| 77 | + await step("A bare entry (`/`) returns to org A, not the session's org B", async () => { |
| 78 | + await page.goto("/", { waitUntil: "networkidle" }); |
| 79 | + await page.waitForURL( |
| 80 | + (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`, |
| 81 | + { |
| 82 | + timeout: 30_000, |
| 83 | + }, |
| 84 | + ); |
| 85 | + await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); |
| 86 | + }); |
| 87 | + |
| 88 | + await step("A bare deep link keeps its path while landing in org A", async () => { |
| 89 | + await page.goto("/policies", { waitUntil: "networkidle" }); |
| 90 | + await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 }); |
| 91 | + await page.getByText("Policies").first().waitFor({ timeout: 30_000 }); |
| 92 | + }); |
| 93 | + }); |
| 94 | + }), |
| 95 | +); |
0 commit comments