From 993bf362c71c71766c913cb4c166ea9b718f8d62 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:43:09 -0800 Subject: [PATCH] Test that v1 creates no organizations and sends org-less sessions to v2 --- .../account/organization-limits.node.test.ts | 52 +---- apps/cloud/src/edge/marketing.test.ts | 27 +++ e2e/cloud/account-api.test.ts | 2 +- e2e/cloud/admin-users-console.test.ts | 9 +- e2e/cloud/admin-users.test.ts | 8 +- e2e/cloud/auth-routing-flow.test.ts | 118 +++++++---- .../billing-customer-provisioning.test.ts | 37 +--- e2e/cloud/connection-owner-isolation.test.ts | 18 +- e2e/cloud/login-csrf.test.ts | 12 +- .../mcp-browser-approval-org-scope.test.ts | 37 +--- e2e/cloud/oauth-callback-org-scope.test.ts | 37 +--- e2e/cloud/onboarding-mcp-url.test.ts | 66 ------ e2e/cloud/org-creation-moved.test.ts | 196 ++++++++++++++++++ e2e/cloud/org-delete.test.ts | 33 +-- e2e/cloud/org-last-visited.test.ts | 43 +--- e2e/cloud/org-limit.test.ts | 106 ---------- e2e/cloud/org-multitab-cookie.test.ts | 45 ++-- e2e/cloud/org-slug-foreign.test.ts | 46 +--- e2e/cloud/org-switcher.test.ts | 126 ++++------- e2e/cloud/session-gate.test.ts | 66 ++++-- e2e/cloud/support/session.ts | 62 ++++-- e2e/scripts/ci-shard-durations.ts | 1 - e2e/scripts/record-connect-card.ts | 26 +-- e2e/src/target.ts | 2 +- e2e/targets/cloud.ts | 71 ++++--- 25 files changed, 581 insertions(+), 665 deletions(-) delete mode 100644 e2e/cloud/onboarding-mcp-url.test.ts create mode 100644 e2e/cloud/org-creation-moved.test.ts delete mode 100644 e2e/cloud/org-limit.test.ts diff --git a/apps/cloud/src/account/organization-limits.node.test.ts b/apps/cloud/src/account/organization-limits.node.test.ts index 502c96a0fe..f8b77a4e23 100644 --- a/apps/cloud/src/account/organization-limits.node.test.ts +++ b/apps/cloud/src/account/organization-limits.node.test.ts @@ -1,28 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { - FREE_ORGANIZATIONS_PER_USER_LIMIT, - hasNonFreeOrganizationSubscription, - hasPaidOrganizationSubscription, - isOverFreeOrganizationLimit, - shouldApplyFreeOrganizationLimit, -} from "../extensions/billing/plans"; +import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans"; describe("organization limits", () => { - it("treats active and trialing paid org subscriptions as paid", () => { - expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); - expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true); - expect(hasPaidOrganizationSubscription([{ planId: "enterprise", status: "active" }])).toBe( - true, - ); - }); - - it("does not treat inactive paid plans or free plans as paid", () => { - expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false); - expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false); - expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); - }); - it("exempts any active non-free subscription from the execution rate limit", () => { expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); expect(hasNonFreeOrganizationSubscription([{ planId: "enterprise", status: "trialing" }])).toBe( @@ -53,34 +33,4 @@ describe("organization limits", () => { ); expect(hasNonFreeOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); }); - - it("applies the free org limit only when none of the user's active orgs are paid", () => { - const activeMemberships = [ - { organizationId: "org_free_1", status: "active" }, - { organizationId: "org_paid", status: "active" }, - ]; - - expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true); - expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false); - }); - - it("caps free-only users at active org memberships, not pending invitations", () => { - expect( - isOverFreeOrganizationLimit( - Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({ - organizationId: `org_${index}`, - status: "active", - })), - ), - ).toBe(false); - - expect( - isOverFreeOrganizationLimit( - Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({ - organizationId: `org_${index}`, - status: "active", - })), - ), - ).toBe(true); - }); }); diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index 146ea0c2ef..14d3c29dd1 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -7,8 +7,10 @@ import { isV2Path, marketingProxyRequest, parseV2Edge, + v2AppOrigin, v2EdgeResponse, v2PageForSlashedPath, + v2SignUpUrl, type V2EdgeEnv, type V2Service, } from "./marketing"; @@ -254,6 +256,31 @@ describe("parseV2Edge", () => { }); }); +// Where v1 sends anything that would create an organization: v2's sign-up page +// (and its origin, v2's app host). +describe("v2SignUpUrl", () => { + const service: V2Service = { fetch: () => Promise.resolve(new Response(null)) }; + const settings = { + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + it("is v2's sign-up page, query kept, and v2AppOrigin is its origin", () => { + expect(v2SignUpUrl(settings)?.href).toBe("https://app.executor.sh/login?mode=signup"); + expect(v2AppOrigin(settings)?.href).toBe("https://app.executor.sh/"); + }); + + it("is null where v1 has no v2: no settings, or broken ones", () => { + expect(v2SignUpUrl({})).toBeNull(); + expect(v2SignUpUrl({ ...settings, V2: undefined })).toBeNull(); + expect(v2SignUpUrl({ ...settings, V2_SIGN_UP_URL: "/login?mode=signup" })).toBeNull(); + expect(v2AppOrigin({})).toBeNull(); + }); +}); + describe("v2EdgeResponse", () => { const SIGN_UP_URL = "https://v2.executor.sh/login?mode=signup"; const STATE_PREFIX = "x2."; diff --git a/e2e/cloud/account-api.test.ts b/e2e/cloud/account-api.test.ts index 7d264d4f2f..31485cafe1 100644 --- a/e2e/cloud/account-api.test.ts +++ b/e2e/cloud/account-api.test.ts @@ -2,7 +2,7 @@ // React shell + api-keys page consume. Happy paths go through the typed // AccountHttpApi client (the same contract the UI's AccountApiClient uses); // refusal edges and the bearer cross-check use raw fetch with the identity's -// real sealed-session cookie (the org-limit cross-check pattern). +// real sealed-session cookie. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { AccountHttpApi } from "@executor-js/api"; diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index 4882e35985..eddf65606f 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -8,10 +8,11 @@ // most here — that a plain member who reaches the URL is told they don't have // access rather than shown an empty workspace. // -// Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation, in `./support/session`). Each connects a Personal -// credential; the plain member's Workspace attempt is refused, so the directory -// and connection UI prove the owner-aware permission boundary together. +// Two members are built through the REAL flows (login into the admin's +// organization → invite → accept-invitation, in `./support/session`). Each +// connects a Personal credential; the plain member's Workspace attempt is +// refused, so the directory and connection UI prove the owner-aware permission +// boundary together. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; diff --git a/e2e/cloud/admin-users.test.ts b/e2e/cloud/admin-users.test.ts index 2be5a29301..565c824b0e 100644 --- a/e2e/cloud/admin-users.test.ts +++ b/e2e/cloud/admin-users.test.ts @@ -3,10 +3,10 @@ // "who uses my workspace, and what have they connected", reading across every // member of the tenant instead of binding to one. // -// Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation), each connects their own Personal credential, and -// the admin then reads the joined view — the exact shape a customer dashboard's -// icon grid consumes. The guarantees pinned here: +// Two members are built through the REAL flows (login into the admin's +// organization → invite → accept-invitation), each connects their own Personal +// credential, and the admin then reads the joined view — the exact shape a +// customer dashboard's icon grid consumes. The guarantees pinned here: // // 1. the joined view reports BOTH members and each one's own connections, // even though no single product-view caller can see another member's; diff --git a/e2e/cloud/auth-routing-flow.test.ts b/e2e/cloud/auth-routing-flow.test.ts index 30efb17cdd..a0ba212521 100644 --- a/e2e/cloud/auth-routing-flow.test.ts +++ b/e2e/cloud/auth-routing-flow.test.ts @@ -2,9 +2,10 @@ // through the real dev server (the e2e cloud target boots `vite dev` with the // SSR gate active, so this is the same routing that runs in prod): // -// signed out → /login -// click Sign in → WorkOS (emulator) callback → org-less → /create-org -// create the first org → canonical dashboard at / +// member: signed out → /login → click Sign in → WorkOS (emulator) callback +// → canonical dashboard at / +// no org: the same sign-in → Executor v2's sign-up page (v1 creates no +// organizations; they are created in v2) // // The individual hops are also covered piecewise (cloud/unauthenticated-skeleton, // cloud/session-gate, scenarios/org-slug-routing) — those pin edge cases this @@ -20,60 +21,53 @@ import { randomUUID } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; +import type { Page } from "playwright"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { settle } from "../src/surfaces/browser"; +import { settle, type BrowserSession } from "../src/surfaces/browser"; +import { seedOrganization } from "../targets/cloud"; + +/** Signed out at the root, then through the hosted sign-in form as `email`. */ +const signInThroughTheUi = async (page: Page, step: BrowserSession["step"], email: string) => { + await step("Signed out, the root redirects to /login", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.getByText("Sign in to manage your tools and integrations").waitFor(); + }); + expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login"); + + await step("Sign in through the UI", async () => { + await page.getByRole("link", { name: "Sign in" }).click(); + // The emulator's hosted AuthKit form. + await page.getByPlaceholder("new-user@example.com").fill(email); + await page.getByRole("button", { name: /Continue/ }).click(); + }); +}; scenario( - "Auth routing · signed out → login → onboarding → dashboard, one browser session", + "Auth routing · signed out → login → dashboard, one browser session", {}, Effect.gen(function* () { const browser = yield* Browser; yield* Target; - // A brand-new user the emulator provisions on first login_hint — no org, so - // the post-callback document is gated to onboarding. + // A member of an existing organization (it lives in WorkOS, as every v1 + // organization does), signing in for the first time in this browser. const email = `flow-${randomUUID().slice(0, 8)}@e2e.test`; + yield* Effect.promise(() => seedOrganization(email, "Flow Test Org")); // No cookies → a clean, signed-out browser. const anonymous = { label: "anonymous" }; yield* browser.session(anonymous, async ({ page, step }) => { - await step("Signed out, the root redirects to /login", async () => { - await page.goto("/", { waitUntil: "commit" }); - await page.getByText("Sign in to manage your tools and integrations").waitFor(); - }); - expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login"); - - await step("Sign in through the UI → org-less session lands on onboarding", async () => { - await page.getByRole("link", { name: "Sign in" }).click(); - // The emulator's hosted AuthKit form: provision a brand-new user. - await page.getByPlaceholder("new-user@example.com").fill(email); - await page.getByRole("button", { name: /Continue/ }).click(); - // callback mints the session → SSR gate sees no org → /create-org. - await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); - await page.getByPlaceholder("Northwind Labs").waitFor({ timeout: 30_000 }); - }); + await signInThroughTheUi(page, step, email); - await step("Create the first org → canonical dashboard at /", async () => { - const orgName = "Flow Test Org"; - const orgNameInput = page.getByPlaceholder("Northwind Labs"); - await settle(page); - await orgNameInput.fill(orgName); - await page.waitForTimeout(250); - if ((await orgNameInput.inputValue()) !== orgName) { - await orgNameInput.fill(orgName); - } - expect(await orgNameInput.inputValue(), "org name survives create-org hydration").toBe( - orgName, - ); - await page.getByRole("button", { name: "Create organization" }).click(); - await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); - await page.getByRole("button", { name: "Continue to app" }).click(); - // The bare landing canonicalizes onto the new org's slug. + await step("The callback lands on the canonical dashboard at /", async () => { + // The bare landing canonicalizes onto the org's slug. await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByRole("button", { name: /Flow Test Org/ }).waitFor({ timeout: 30_000 }); }); expect( @@ -83,3 +77,51 @@ scenario( }); }), ); + +scenario( + "Auth routing · a sign-in with no organization is sent to Executor v2's sign-up page", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + + const email = `flow-orgless-${randomUUID().slice(0, 8)}@e2e.test`; + const anonymous = { label: "anonymous" }; + + yield* browser.session(anonymous, async ({ page, step }) => { + // The callback's redirect chain ends on v2's real host, and Playwright + // cannot hold a document at a redirect hop. So the hosted form's answer + // is held here (as login-csrf does), and the browser's own request + // context — its cookie jar — follows the chain one hop at a time. + let callback: string | undefined; + await page.route("**/user_management/authorize/submit", async (route) => { + const response = await route.fetch({ maxRedirects: 0 }); + callback = response.headers().location; + await route.fulfill({ status: 200, contentType: "text/plain", body: "Signed in" }); + }); + + await signInThroughTheUi(page, step, email); + + await step("The callback signs the browser in and returns it to the app", async () => { + await expect.poll(() => callback).toBeDefined(); + const response = await page.request.get(callback!, { maxRedirects: 0 }); + expect(response.status(), "the callback completes the sign-in").toBe(302); + expect(response.headers().location, "…back to the app's root").toBe("/"); + }); + + await step("The app sends the org-less session on to v2's sign-up page", async () => { + const response = await page.request.get(new URL("/", target.baseUrl).toString(), { + maxRedirects: 0, + headers: { accept: "text/html" }, + }); + expect(response.status(), "no app shell for a session with no organization").toBe(302); + const location = new URL(response.headers().location ?? "", target.baseUrl); + expect(location.origin, "the session leaves v1 for v2's host").not.toBe( + new URL(target.baseUrl).origin, + ); + expect(location.pathname, "v2's sign-in page…").toBe("/login"); + expect(location.searchParams.get("mode"), "…opened to sign up").toBe("signup"); + }); + }); + }), +); diff --git a/e2e/cloud/billing-customer-provisioning.test.ts b/e2e/cloud/billing-customer-provisioning.test.ts index ab98cfd27d..9d3087129b 100644 --- a/e2e/cloud/billing-customer-provisioning.test.ts +++ b/e2e/cloud/billing-customer-provisioning.test.ts @@ -7,16 +7,18 @@ // executions and NONE of them reach the meter. The product looks perfectly // healthy while every execution is unbilled and unmetered. // -// Three guarantees are pinned here, all read from Autumn's own state rather than +// Two guarantees are pinned here, all read from Autumn's own state rather than // from the execution's response (which can never show any of them): // -// 1. creating an organization provisions its billing customer up front, -// 2. if a customer is missing anyway, the billing seam heals it in place and -// the execution still lands on the meter, and -// 3. any OTHER billing failure is left alone — the repair is scoped to the +// 1. if an organization's customer is missing, the billing seam heals it in +// place and the execution still lands on the meter, and +// 2. any OTHER billing failure is left alone — the repair is scoped to the // "no such customer" answer, so a real provider failure is still reported // rather than quietly retried away. // +// (v1 no longer creates organizations — they are created in Executor v2 — so +// there is no creation step left to provision a customer up front.) +// // The missing customer is produced with the emulator's fault injector — one // 404 `customer_not_found` per billing endpoint — because the emulator, like // real Autumn's SDK flow, otherwise auto-creates customers on contact. @@ -54,31 +56,6 @@ const UNRELATED_NOT_FOUND = { body: { message: "Not Found", code: "not_found" }, } as const; -scenario( - "Billing · creating an organization provisions it as a billing customer", - { timeout: 180_000 }, - Effect.gen(function* () { - yield* Billing; - const autumn = yield* Autumn; - const target = yield* Target; - const mcp = yield* Mcp; - - // A brand-new user creating their first organization: none of the - // opportunistic billing lookups (the over-the-free-limit check, the members - // page's seat count, the rate limiter's paid-plan exemption) are reached on - // this journey, so org creation is the only chance to provision. - const identity = yield* target.newIdentity(); - const bearer = yield* mcp.mintBearer(emailOf(identity)); - const organizationId = orgIdOf(bearer); - - const customerIds = yield* autumn.customerIds(); - expect( - customerIds, - "the new organization exists as a customer at the billing provider", - ).toContain(organizationId); - }), -); - scenario( "Billing · a missing billing customer is healed in place and the execution is still metered", { timeout: 180_000 }, diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index a20a2003f5..72e2385d84 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -2,8 +2,9 @@ import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only: the connection OWNER model, with real multi-user organizations. // Every connection is filed under `owner: "org"` (shared with the whole tenant) // or `owner: "user"` (this subject's own). The org membership is built through -// the real product flows — invite → accept-invitation, create-organization, -// switch-organization — so the guarantees hold for genuine sessions: +// the real product flows — invite → accept-invitation, and a second org the +// account belongs to in WorkOS (v1 creates none; see `addOrg`) — so the +// guarantees hold for genuine sessions: // // 1. A user-owned connection is private to its creator, even from co-workers // in the same org (personal OAuth tokens don't leak to colleagues). @@ -22,6 +23,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { scenario } from "../src/scenario"; import { Api, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; +import { addOrg } from "./support/session"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -127,14 +129,6 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); }); -/** Create another org for this account; returns the identity bound to it. */ -const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) => - Effect.gen(function* () { - const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); - const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; - return withRefreshedSession(identity, response, created.id); - }); - // `/api/auth/switch-organization` (session-cookie-based org switching) was // removed in #1000 (commit 1f9bfe06b): the URL is now the scope authority, not // the session. A request picks its active org via the `x-executor-organization` @@ -297,8 +291,8 @@ scenario( }, }); - // The SAME account creates and switches into a second org. - const userInOrgB = yield* createAnotherOrg( + // The SAME account in a second org. + const { identity: userInOrgB } = yield* addOrg( target, userInOrgA, `Second Org ${randomBytes(3).toString("hex")}`, diff --git a/e2e/cloud/login-csrf.test.ts b/e2e/cloud/login-csrf.test.ts index 183eb72a16..1c19f8c863 100644 --- a/e2e/cloud/login-csrf.test.ts +++ b/e2e/cloud/login-csrf.test.ts @@ -5,6 +5,10 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { seedOrganization } from "../targets/cloud"; + +/** A signed-in member's landing: the dashboard at their org's `/`. */ +const isDashboard = (url: URL) => /^\/[a-z0-9-]+\/?$/.test(url.pathname); scenario( "Login CSRF · state is required, bound to the browser, and consumed after login", @@ -13,6 +17,8 @@ scenario( const target = yield* Target; const browser = yield* Browser; const email = `csrf-${randomUUID()}@e2e.test`; + // A member of an existing organization, so the completed login lands in the app. + yield* Effect.promise(() => seedOrganization(email, "CSRF Org")); yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { const interceptCallback = async (): Promise => { let callback: string | undefined; @@ -59,7 +65,7 @@ scenario( await step("Complete a fresh login, then reject the same callback again", async () => { const callback = await interceptCallback(); await page.goto(callback); - await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); + await page.waitForURL(isDashboard, { timeout: 30_000 }); const cookies = await page.context().cookies(); expect(cookies.some((cookie) => cookie.name === "wos-session")).toBe(true); expect(cookies.some((cookie) => cookie.name === "wos-login-state")).toBe(false); @@ -81,6 +87,8 @@ scenario( const target = yield* Target; const browser = yield* Browser; const email = `provider-login-${randomUUID()}@e2e.test`; + // A member of an existing organization, so the completed login lands in the app. + yield* Effect.promise(() => seedOrganization(email, "Provider Login Org")); // Discover this deployment's provider URL without setting a browser cookie. const login = yield* Effect.promise(() => fetch(new URL("/api/auth/login", target.baseUrl), { redirect: "manual" }), @@ -113,7 +121,7 @@ scenario( // The emulator asks again; hosted AuthKit can reuse its browser session. await page.getByPlaceholder("new-user@example.com").fill(email); await page.getByRole("button", { name: /Continue/ }).click(); - await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); + await page.waitForURL(isDashboard, { timeout: 30_000 }); const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString()); expect(me.status()).toBe(200); expect(await me.json()).toMatchObject({ user: { email } }); diff --git a/e2e/cloud/mcp-browser-approval-org-scope.test.ts b/e2e/cloud/mcp-browser-approval-org-scope.test.ts index 7c2e158f15..22672a1ec7 100644 --- a/e2e/cloud/mcp-browser-approval-org-scope.test.ts +++ b/e2e/cloud/mcp-browser-approval-org-scope.test.ts @@ -15,6 +15,7 @@ import { scenario } from "../src/scenario"; import { Api, Browser, Mcp, Target } from "../src/services"; import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; import { visit } from "../src/surfaces/browser"; +import { addOrg } from "./support/session"; const coreApi = composePluginApi([] as const); @@ -25,13 +26,6 @@ const result = await tools.executor.coreTools.policies.list({}); return JSON.stringify(result); `; -const cookiePair = (response: Response, name: string): string | undefined => { - for (const header of response.headers.getSetCookie?.() ?? []) { - if (header.startsWith(`${name}=`)) return header.split(";")[0]; - } - return undefined; -}; - const cookieValue = (pair: string): string => { const [, value] = pair.split(/=(.*)/s); if (!value) throw new Error("cookie pair has no value"); @@ -41,8 +35,6 @@ const cookieValue = (pair: string): string => { const cookieOf = (identity: { readonly headers?: Record }): string => identity.headers?.cookie ?? ""; -const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); - const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => { @@ -82,26 +74,6 @@ const activeOrg = (baseUrl: string, cookie: string) => return body.organization; }); -const createOrganization = (baseUrl: string, cookie: string, name: string) => - Effect.promise(async () => { - const response = await fetch(new URL("/api/auth/create-organization", baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - cookie, - ...originHeaders(baseUrl), - }, - body: JSON.stringify({ name }), - }); - if (!response.ok) { - throw new Error(`/api/auth/create-organization failed (${response.status})`); - } - const session = cookiePair(response, "wos-session"); - if (!session) throw new Error("create organization did not refresh the session"); - const org = (await response.json()) as { id: string; name: string; slug: string }; - return { org, session }; - }); - const approvalApiRequest = (approval: McpBrowserApproval, method: "GET" | "POST") => (request: Request): boolean => { @@ -157,11 +129,12 @@ scenario( `/${orgA.slug}/resume/${approval.executionId}`, ); - const { org: orgB, session: sessionB } = yield* createOrganization( - target.baseUrl, - sessionA, + const { org: orgB, identity: inB } = yield* addOrg( + target, + identity, `MCP Approval Org B ${randomBytes(3).toString("hex")}`, ); + const sessionB = cookieOf(inB); expect(orgB.slug, "the browser can be pinned to a different org").not.toBe(orgA.slug); const [resumed] = yield* Effect.all( diff --git a/e2e/cloud/oauth-callback-org-scope.test.ts b/e2e/cloud/oauth-callback-org-scope.test.ts index 66f1f50415..50de9ada43 100644 --- a/e2e/cloud/oauth-callback-org-scope.test.ts +++ b/e2e/cloud/oauth-callback-org-scope.test.ts @@ -18,18 +18,12 @@ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity } from "../src/target"; import { visit } from "../src/surfaces/browser"; +import { addOrg } from "./support/session"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; -const cookiePair = (response: Response, name: string): string | undefined => { - for (const header of response.headers.getSetCookie?.() ?? []) { - if (header.startsWith(`${name}=`)) return header.split(";")[0]; - } - return undefined; -}; - const cookieValue = (pair: string): string => { const [, value] = pair.split(/=(.*)/s); if (!value) throw new Error("cookie pair has no value"); @@ -38,8 +32,6 @@ const cookieValue = (pair: string): string => { const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; -const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); - const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => { @@ -115,26 +107,6 @@ const activeOrg = (baseUrl: string, cookie: string) => return body.organization; }); -const createOrganization = (baseUrl: string, cookie: string, name: string) => - Effect.promise(async () => { - const response = await fetch(new URL("/api/auth/create-organization", baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - cookie, - ...originHeaders(baseUrl), - }, - body: JSON.stringify({ name }), - }); - if (!response.ok) { - throw new Error(`/api/auth/create-organization failed (${response.status})`); - } - const session = cookiePair(response, "wos-session"); - if (!session) throw new Error("create organization did not refresh the session"); - const org = (await response.json()) as { id: string; name: string; slug: string }; - return { org, session }; - }); - const oauthIntegrationSpec = (oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string; @@ -180,11 +152,12 @@ scenario( const sessionA = cookieOf(identity); const orgA = yield* activeOrg(target.baseUrl, sessionA); - const { org: orgB, session: sessionB } = yield* createOrganization( - target.baseUrl, - sessionA, + const { org: orgB, identity: inB } = yield* addOrg( + target, + identity, `OAuth Callback Org B ${randomBytes(3).toString("hex")}`, ); + const sessionB = cookieOf(inB); expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug); const client = yield* makeApiClient(api, identity); diff --git a/e2e/cloud/onboarding-mcp-url.test.ts b/e2e/cloud/onboarding-mcp-url.test.ts deleted file mode 100644 index 84de5e0a70..0000000000 --- a/e2e/cloud/onboarding-mcp-url.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Cloud-specific (browser): the onboarding MCP-setup step gives the user an -// org-scoped MCP server URL and a matching install command. Driven through the -// real web UI as a fresh user who has no organization yet. -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Browser, Target } from "../src/services"; -import { visit } from "../src/surfaces/browser"; - -scenario( - "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", - {}, - Effect.gen(function* () { - const target = yield* Target; - const browser = yield* Browser; - const identity = yield* target.newIdentity({ org: false }); - - yield* browser.session(identity, async ({ page, step }) => { - await step( - "A fresh user without an org lands on the create-org onboarding page", - async () => { - await visit(page, "/"); - // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. - await page.getByPlaceholder("Northwind Labs").waitFor(); - }, - ); - - await step("Create an organization to advance to the MCP setup step", async () => { - await page.getByPlaceholder("Northwind Labs").fill("Test Org"); - await page.getByRole("button", { name: "Create organization" }).click(); - // Successful creation navigates to the 'Connect your MCP client' step. - await page.getByText("Connect your MCP client").waitFor(); - }); - - await step("Read the MCP server URL displayed on the setup page", async () => { - const urlSection = page.getByRole("region", { name: "MCP server URL" }); - await urlSection.waitFor(); - // Wait until the endpoint is populated (the component defers origin to useEffect). - await page.waitForFunction(() => { - const section = document.querySelector('[aria-label="MCP server URL"]'); - const span = section?.querySelector("span.font-mono"); - return span && span.textContent !== "…" && span.textContent !== ""; - }); - }); - - const mcpUrlSection = page.getByRole("region", { name: "MCP server URL" }); - const mcpUrl = await mcpUrlSection.locator("span.font-mono").innerText(); - // Org-scoped via the org's URL slug (the readable form), not the raw - // WorkOS org_… id — and not the bare /mcp path. - expect(mcpUrl, "MCP URL is slug-scoped").toMatch(/\/[a-z0-9-]+\/mcp/); - expect(mcpUrl, "the slug form, not the org_ id").not.toMatch(/\/org_[^/]+\/mcp/); - - const installSection = page.getByRole("region", { name: "Install command" }); - await installSection.waitFor(); - const installCommand = await installSection.locator("code").innerText(); - - // The install command must reference the SAME org as the displayed URL - // — not a different one or a bare /mcp path. - const orgSlug = /\/([a-z0-9-]+)\/mcp/.exec(mcpUrl)?.[1] ?? "(no org segment in MCP URL)"; - expect(installCommand, "the install command references the same org").toContain( - `/${orgSlug}/mcp`, - ); - }); - }), -); diff --git a/e2e/cloud/org-creation-moved.test.ts b/e2e/cloud/org-creation-moved.test.ts new file mode 100644 index 0000000000..127aee91fa --- /dev/null +++ b/e2e/cloud/org-creation-moved.test.ts @@ -0,0 +1,196 @@ +// Cloud-only: v1 creates no organizations — every new one is created in +// Executor v2 — and the one way an account without an organization still +// joins one here, a pending invitation into an existing organization, works as +// it always has. +// +// 1. `POST /api/auth/create-organization` answers `OrganizationCreationMoved` +// with v2's sign-up page, for an account with no organization and for a +// member alike, and nothing is created: no WorkOS organization or +// membership, no change to the account's organizations, no new session. +// 2. An invited account with no organization is sent to /join instead of +// v2, accepts there, and lands in the organization it was invited to. +import { connectEmulator } from "@executor-js/emulate"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import type { Identity, Target as TargetShape } from "../src/target"; +import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; +import { verifyAdmin } from "./support/admin-mfa"; +import { activeOrg, cookieOf, organizationsOf, postJson } from "./support/session"; + +const WORKOS_URL = `http://127.0.0.1:${WORKOS_EMULATOR_PORT}`; + +/** The account's WorkOS id: the user `/api/auth/me` reports. */ +const userIdOf = (target: TargetShape, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const response = await fetch(new URL("/api/auth/me", target.baseUrl), { + headers: { cookie: cookieOf(identity) }, + }); + if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); + return ((await response.json()) as { user: { id: string } }).user.id; + }); + +/** The organizations WorkOS itself lists the user in — the system of record. */ +const workosMembershipsOf = (userId: string): Effect.Effect> => + Effect.promise(async () => { + const response = await fetch( + `${WORKOS_URL}/user_management/organization_memberships?user_id=${encodeURIComponent(userId)}`, + { headers: { authorization: "Bearer sk_test_emulate" } }, + ); + if (!response.ok) throw new Error(`WorkOS list memberships failed (${response.status})`); + const body = (await response.json()) as { data: ReadonlyArray<{ organization_id: string }> }; + return body.data.map((membership) => membership.organization_id).sort(); + }); + +/** WorkOS create-organization / create-membership requests since `since`. */ +const workosCreationsSince = (since: number): Effect.Effect> => + Effect.promise(async () => { + const workos = await connectEmulator({ baseUrl: WORKOS_URL }); + const entries = await workos.ledger.list(1000); + return entries + .filter( + (entry) => + Date.parse(entry.timestamp) >= since && + entry.method === "POST" && + (entry.path === "/organizations" || + entry.path === "/user_management/organization_memberships"), + ) + .map((entry) => entry.summary); + }); + +/** The v2 sign-up page the auth gate sends a session with no organization to. */ +const v2SignUpUrlOf = (target: TargetShape, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const response = await fetch(new URL("/create-org", target.baseUrl), { + redirect: "manual", + headers: { accept: "text/html", cookie: cookieOf(identity) }, + }); + if (response.status !== 302) throw new Error(`/create-org answered ${response.status}`); + return response.headers.get("location") ?? ""; + }); + +const createOrganization = (target: TargetShape, identity: Identity) => + Effect.promise(() => + fetch(new URL("/api/auth/create-organization", target.baseUrl), { + method: "POST", + headers: { + ...(identity.headers ?? {}), + "content-type": "application/json", + origin: new URL(target.baseUrl).origin, + }, + body: JSON.stringify({ name: "Should Not Exist" }), + }), + ); + +scenario( + "Org creation · create-organization creates nothing and answers with Executor v2", + { timeout: 120_000 }, + Effect.gen(function* () { + yield* Api; + const target = yield* Target; + + const orgless = yield* target.newIdentity({ org: false }); + const member = yield* target.newIdentity(); + const v2SignUp = yield* v2SignUpUrlOf(target, orgless); + expect(new URL(v2SignUp).origin, "v2's sign-up page is on v2's host").not.toBe( + new URL(target.baseUrl).origin, + ); + + for (const [who, identity] of [ + ["an account with no organization", orgless], + ["a member of an organization", member], + ] as const) { + const userId = yield* userIdOf(target, identity); + const workosBefore = yield* workosMembershipsOf(userId); + const orgsBefore = yield* organizationsOf(target, identity); + const since = Date.now(); + + const response = yield* createOrganization(target, identity); + const body = (yield* Effect.promise(() => response.json())) as { + readonly _tag?: string; + readonly message?: string; + readonly url?: string | null; + }; + expect(response.status, `${who} is refused: creation has moved`).toBe(410); + expect(body, `${who} is told where organizations are created now`).toEqual({ + _tag: "OrganizationCreationMoved", + message: "Executor has moved. New organizations are created in Executor v2.", + url: v2SignUp, + }); + expect( + response.headers.getSetCookie().filter((cookie) => cookie.startsWith("wos-session=")), + `${who}'s session is left alone`, + ).toEqual([]); + + expect( + yield* workosMembershipsOf(userId), + `WorkOS lists ${who} in the same organizations`, + ).toEqual(workosBefore); + expect( + (yield* organizationsOf(target, identity)).organizations, + `${who} has the same organizations in v1`, + ).toEqual(orgsBefore.organizations); + expect( + yield* workosCreationsSince(since), + "WorkOS was asked to create no organization or membership", + ).toEqual([]); + } + }), +); + +scenario( + "Org creation · an invited account with no organization accepts on /join and lands in the org", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Api; + const target = yield* Target; + const browser = yield* Browser; + + const admin = yield* target.newIdentity(); + const org = yield* activeOrg(target, admin); + const invitee = yield* target.newIdentity({ org: false }); + + // The real invite flow into the admin's existing organization. + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { + email: invitee.credentials?.email, + }); + + // With a pending invitation, the gate sends the account to /join, not v2. + const gated = yield* Effect.promise(() => + fetch(new URL("/", target.baseUrl), { + redirect: "manual", + headers: { accept: "text/html", cookie: cookieOf(invitee) }, + }), + ); + expect(gated.status, "the app is not served without an organization").toBe(302); + expect(gated.headers.get("location"), "…the invitation is accepted on /join").toBe("/join"); + + yield* browser.session(invitee, async ({ page, step }) => { + await step("Open the app: the pending invitation is shown", async () => { + await visit(page, "/"); + await page.waitForURL((url) => url.pathname === "/join", { timeout: 30_000 }); + await page.getByRole("heading", { name: "You've been invited" }).waitFor(); + await page.getByRole("region", { name: "Invitation" }).getByText(org.name).waitFor(); + }); + + await step("Accept the invitation and land in the organization", async () => { + await page.getByRole("button", { name: "Accept invitation" }).click(); + await page.waitForURL( + (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`, + { timeout: 30_000 }, + ); + await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); + }); + }); + + const { organizations } = yield* organizationsOf(target, invitee); + expect( + organizations.map((organization) => organization.slug), + "the invitee is now a member of exactly the organization they were invited to", + ).toEqual([org.slug]); + }), +); diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index c096c2ca5b..c258f7e8a8 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -1,7 +1,8 @@ import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-specific (browser): an admin permanently deletes their organization. -// A fresh user creates an org through onboarding, opens Organization settings, -// and uses the danger-zone "Delete organization" flow — which requires +// A user who is the admin of an org (it exists in WorkOS; v1 creates none, see +// `addOrg`) opens Organization settings, and uses the danger-zone "Delete +// organization" flow — which requires // re-typing the org name to confirm. Deleting tears the org down in WorkOS + // billing + the tenant database and clears the session, so the app drops the // user out of the (now gone) workspace. @@ -10,7 +11,8 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { visit, settle } from "../src/surfaces/browser"; +import { visit } from "../src/surfaces/browser"; +import { addOrg } from "./support/session"; scenario( "Organizations · an admin deletes the organization from settings", @@ -18,27 +20,14 @@ scenario( Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; - const identity = yield* target.newIdentity({ org: false }); + const ORG = "Doomed Org"; + const account = yield* target.newIdentity({ org: false }); + const { + org: { slug }, + identity, + } = yield* addOrg(target, account, ORG); yield* browser.session(identity, async ({ page, step }) => { - const ORG = "Doomed Org"; - - await step("Fresh user creates an org via onboarding", async () => { - await visit(page, "/"); - await page.getByPlaceholder("Northwind Labs").fill(ORG); - await page.getByRole("button", { name: "Create organization" }).click(); - await page.getByText("Connect your MCP client").waitFor(); - await page.getByRole("button", { name: "Continue to app" }).click(); - await page.getByText("Integrations").first().waitFor(); - // The console canonicalizes onto the org's URL slug (/doomed-org). - await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { - timeout: 30_000, - }); - await settle(page); - }); - - const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; - await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); await verifyAdminInBrowser(page); diff --git a/e2e/cloud/org-last-visited.test.ts b/e2e/cloud/org-last-visited.test.ts index 6912fce1cc..3edcb18fe6 100644 --- a/e2e/cloud/org-last-visited.test.ts +++ b/e2e/cloud/org-last-visited.test.ts @@ -14,8 +14,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; - -const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); +import { activeOrg, addOrg } from "./support/session"; scenario( "Org URLs · a bare entry lands in the last-visited org, not the session's login org", @@ -24,45 +23,15 @@ scenario( const target = yield* Target; const browser = yield* Browser; - // Identity starts in org A. Create org B through the real endpoint, which - // returns the refreshed cookie — the SESSION is now pinned to org B, so - // without the last-org cookie every bare entry would land in B. + // Identity starts in org A, then joins org B and signs in to it — the + // SESSION is pinned to org B, so without the last-org cookie every bare + // entry would land in B. const identity = yield* target.newIdentity(); - const cookie = identity.headers?.cookie ?? ""; - - const createB = yield* Effect.promise(() => - fetch(new URL("/api/auth/create-organization", target.baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - cookie, - ...CLOUD_ORIGIN_HEADERS(target.baseUrl), - }, - body: JSON.stringify({ name: "Last Visited Org B" }), - }), - ); - expect(createB.ok, "org B was created").toBe(true); - const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string }; - const setCookie = createB.headers.get("set-cookie") ?? ""; - const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1]; - expect(sessionB, "creating org B pinned the session into it").toBeTruthy(); - - const orgs = (yield* Effect.promise(() => - fetch(new URL("/api/auth/organizations", target.baseUrl), { - headers: { cookie: `wos-session=${sessionB}` }, - }).then((r) => r.json()), - )) as { organizations: ReadonlyArray<{ name: string; slug: string }> }; - const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug; - expect(slugA, "org A has a slug").toBeTruthy(); + const { slug: slugA } = yield* activeOrg(target, identity); + const { org: orgB, identity: inB } = yield* addOrg(target, identity, "Last Visited Org B"); expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug); // Drive the browser as the session pinned to B. - const inB = { - ...identity, - headers: { cookie: `wos-session=${sessionB}` }, - cookies: [{ name: "wos-session", value: sessionB! }], - }; - yield* browser.session(inB, async ({ page, step }) => { await step("Work in org A by its slug URL (the session still pins org B)", async () => { await visit(page, `/${slugA}`); diff --git a/e2e/cloud/org-limit.test.ts b/e2e/cloud/org-limit.test.ts deleted file mode 100644 index dcdb570936..0000000000 --- a/e2e/cloud/org-limit.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Cloud-specific (billing): the free plan allows 3 organizations per user. -// Driven ENTIRELY through the real web UI as a fresh user — the onboarding -// create-org page for the first org, then the in-app account-menu → -// org-switcher → "Create organization" modal for the rest. The run's -// Playwright trace + video + step screenshots are the debugging artifacts. -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Billing, Browser, Target } from "../src/services"; -import { visit, settle } from "../src/surfaces/browser"; - -const FREE_LIMIT = 3; - -scenario( - "Billing · the free plan stops organization creation after 3", - {}, - Effect.gen(function* () { - // Gate: billing limits are enforced on this target. - yield* Billing; - const target = yield* Target; - const browser = yield* Browser; - const identity = yield* target.newIdentity({ org: false }); - - yield* browser.session(identity, async ({ page, step }) => { - await step("A fresh user lands on onboarding (no organization yet)", async () => { - await visit(page, "/"); - await page.getByPlaceholder("Northwind Labs").waitFor(); - }); - - await step(`Create "Acme 1" (1 of ${FREE_LIMIT} allowed on the free plan)`, async () => { - await page.getByPlaceholder("Northwind Labs").fill("Acme 1"); - await page.getByRole("button", { name: "Create organization" }).click(); - // Onboarding step 2 — proves the first org was created. - await page.getByText("Connect your MCP client").waitFor(); - }); - - await step("Continue into the app", async () => { - await page.getByRole("button", { name: "Continue to app" }).click(); - await page.getByText("Integrations").first().waitFor(); - // Let the router navigation fully settle (slow on a cold dev server) - // before opening menus — a late remount closes them mid-interaction. - // The console canonicalizes onto the org's URL slug (/acme-1). - await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { - timeout: 30_000, - }); - await settle(page); - }); - - const openCreateOrgModal = async (currentOrg: string) => { - // Under parallel-suite load the radix menu re-renders while the org - // list loads; a click can land on a closing menu. Bounded retry with - // a clean reopen keeps the journey honest without 30s hangs. - for (let attempt = 1; ; attempt++) { - try { - await page.keyboard.press("Escape"); - await page.getByRole("button", { name: /Test User/ }).click(); - await page.getByRole("menuitem", { name: currentOrg }).click({ timeout: 5_000 }); - const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); - await subContent.waitFor({ state: "visible", timeout: 5_000 }); - await subContent - .getByText("Create organization", { exact: true }) - .click({ timeout: 5_000 }); - await page.getByText("Add another organization").waitFor({ timeout: 5_000 }); - return; - } catch (error) { - if (attempt >= 3) throw error; - } - } - }; - - for (let i = 2; i <= FREE_LIMIT; i++) { - await step(`Open the org switcher and choose "Create organization"`, async () => { - await openCreateOrgModal(`Acme ${i - 1}`); - }); - await step(`Create "Acme ${i}" (${i} of ${FREE_LIMIT})`, async () => { - await page.getByPlaceholder("Northwind Labs").fill(`Acme ${i}`); - await page.getByRole("button", { name: "Create organization" }).click(); - // The modal closes and the session switches into the new org. - await page.getByText("Add another organization").waitFor({ state: "hidden" }); - await page.getByRole("button", { name: new RegExp(`Acme ${i}`) }).waitFor(); - }); - } - - await step("Attempt a 4th organization (over the free limit)", async () => { - await openCreateOrgModal(`Acme ${FREE_LIMIT}`); - await page.getByPlaceholder("Northwind Labs").fill("Acme 4"); - await page.getByRole("button", { name: "Create organization" }).click(); - await page.locator("p.text-destructive").first().waitFor(); - }); - - const errorText = await page.locator("p.text-destructive").first().innerText(); - expect(errorText.length, "the UI shows a visible refusal").toBeGreaterThan(0); - - // Cross-check through the session API, with the browser's own session - // cookie (fetched explicitly — the Secure cookie isn't replayed by - // page.request over plain http). - const cookie = (await page.context().cookies()).map((c) => `${c.name}=${c.value}`).join("; "); - const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), { - headers: { cookie }, - }); - const body = (await response.json()) as { organizations: ReadonlyArray<{ name: string }> }; - expect(body.organizations.length, "exactly the free-plan allowance exists").toBe(FREE_LIMIT); - }); - }), -); diff --git a/e2e/cloud/org-multitab-cookie.test.ts b/e2e/cloud/org-multitab-cookie.test.ts index 75125828ba..a6bd848392 100644 --- a/e2e/cloud/org-multitab-cookie.test.ts +++ b/e2e/cloud/org-multitab-cookie.test.ts @@ -14,14 +14,17 @@ // reproduction of the corruption — now asserts the opposite: each tab's // requests stay scoped to its own URL org, no matter what the other tab does. // -// Everything runs through the browser (onboarding + the menu create-org), so -// the single-use WorkOS refresh-token chain stays browser-owned and valid. +// The account's two orgs exist in WorkOS (v1 creates none; see `addOrg`) and +// the session is signed in before the browser opens; from then on only the +// browser uses it, so the single-use WorkOS refresh-token chain stays +// browser-owned and valid. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; +import { addOrg } from "./support/session"; scenario( "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)", @@ -29,11 +32,14 @@ scenario( Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; - const identity = yield* target.newIdentity({ org: false }); + const account = yield* target.newIdentity({ org: false }); + const { org: orgA } = yield* addOrg(target, account, "Multitab A"); + const { org: orgB, identity } = yield* addOrg(target, account, "Multitab B"); + const slugA = orgA.slug; + const slugB = orgB.slug; + expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA); yield* browser.session(identity, async ({ page: tab1, step }) => { - const slugOf = (page: typeof tab1) => new URL(page.url()).pathname.replace(/^\/|\/.*$/g, ""); - // The org slug a page's REAL app requests carry — read straight off the // outgoing `x-executor-organization` header, the actual request scope. // This is what makes the two tabs independent; the shared session cookie @@ -50,35 +56,10 @@ scenario( return (await matching).headers()["x-executor-organization"]!; }; - let slugA = ""; - let slugB = ""; - - await step("Onboard org A in tab 1", async () => { - await visit(tab1, "/"); - await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); - await tab1.getByRole("button", { name: "Create organization" }).click(); - await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); - await tab1.getByRole("button", { name: "Continue to app" }).click(); - await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); - await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 }); - slugA = slugOf(tab1); - }); - - await step("Create org B from tab 1's account menu — tab 1 is now in B", async () => { - await tab1.getByRole("button", { name: /Test User/ }).click(); - await tab1.getByRole("menuitem", { name: "Multitab A" }).click(); - await tab1 - .locator('[data-slot="dropdown-menu-sub-content"]') - .getByText("Create organization", { exact: true }) - .click(); - await tab1.getByText("Add another organization").waitFor(); - await tab1.getByPlaceholder("Northwind Labs").fill("Multitab B"); - await tab1.getByRole("button", { name: "Create organization" }).click(); - await tab1.waitForURL((url) => url.pathname !== `/${slugA}`, { timeout: 30_000 }); + await step("Tab 1 opens org B", async () => { + await visit(tab1, `/${slugB}`); await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 }); - slugB = slugOf(tab1); }); - expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA); // A second tab in the SAME context — shares tab 1's cookie jar. const tab2 = await tab1.context().newPage(); diff --git a/e2e/cloud/org-slug-foreign.test.ts b/e2e/cloud/org-slug-foreign.test.ts index c50e024d2b..a4c08c9644 100644 --- a/e2e/cloud/org-slug-foreign.test.ts +++ b/e2e/cloud/org-slug-foreign.test.ts @@ -14,8 +14,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; - -const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); +import { activeOrg, addOrg, cookieOf } from "./support/session"; scenario( "Org URLs · opening another of your orgs by slug works without switching the session", @@ -24,48 +23,15 @@ scenario( const target = yield* Target; const browser = yield* Browser; - // Identity starts in org A. Create org B through the real endpoint, which - // returns its refreshed cookie (the session is now pinned to B). + // Identity starts in org A, then joins org B and signs in to it: the + // session is pinned to B. const identity = yield* target.newIdentity(); - const cookie = identity.headers?.cookie ?? ""; - - const createB = yield* Effect.promise(() => - fetch(new URL("/api/auth/create-organization", target.baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - cookie, - ...CLOUD_ORIGIN_HEADERS(target.baseUrl), - }, - body: JSON.stringify({ name: "Foreign Slug Org B" }), - }), - ); - expect(createB.ok, "org B was created").toBe(true); - const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string }; - const setCookie = createB.headers.get("set-cookie") ?? ""; - const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1]; - expect(sessionB, "creating org B pinned the session into it").toBeTruthy(); - - // Both orgs' slugs (the org list is the same whichever org the cookie pins). - const orgs = (yield* Effect.promise(() => - fetch(new URL("/api/auth/organizations", target.baseUrl), { - headers: { cookie: `wos-session=${sessionB}` }, - }).then((r) => r.json()), - )) as { - organizations: ReadonlyArray<{ name: string; slug: string }>; - }; - const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug; - expect(slugA, "org A has a slug").toBeTruthy(); - expect(orgB.slug, "org B has a slug").toBeTruthy(); + const { slug: slugA } = yield* activeOrg(target, identity); + const { org: orgB, identity: inB } = yield* addOrg(target, identity, "Foreign Slug Org B"); + const sessionB = cookieOf(inB).slice("wos-session=".length); expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug); // Drive the browser as the session pinned to B. - const inB = { - ...identity, - headers: { cookie: `wos-session=${sessionB}` }, - cookies: [{ name: "wos-session", value: sessionB! }], - }; - yield* browser.session(inB, async ({ page, step }) => { await step("Land in org B, then open org A's slug URL directly", async () => { await visit(page, `/${orgB.slug}`); diff --git a/e2e/cloud/org-switcher.test.ts b/e2e/cloud/org-switcher.test.ts index 62f77bd4ed..2df2e9a59c 100644 --- a/e2e/cloud/org-switcher.test.ts +++ b/e2e/cloud/org-switcher.test.ts @@ -1,14 +1,18 @@ // Cloud-specific (browser): switching organizations changes the active workspace. -// A fresh user creates two organizations through the real web UI — the first -// via onboarding and the second via the account-menu → org switcher → "Create -// organization" modal — then uses the same switcher to return to the first org -// and confirms the workspace label in the bottom-left account button updates. +// An account in two organizations (they exist in WorkOS; v1 creates none, see +// `addOrg`) opens the second, finds the switcher's link to create an +// organization in Executor v2 where v1's create dialog used to be, then uses +// the switcher to return to the first org and confirms the workspace label in +// the bottom-left account button updates. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; import { visit, settle } from "../src/surfaces/browser"; +import { addOrg } from "./support/session"; + +const NEW_ORG_LINK = "New organization in Executor v2"; scenario( "Organizations · switching organizations switches the workspace", @@ -16,107 +20,61 @@ scenario( Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; - const identity = yield* target.newIdentity({ org: false }); - yield* browser.session(identity, async ({ page, step }) => { - // ── Step 1: onboarding, create the first org ───────────────────── - await step("Fresh user lands on onboarding (no organization yet)", async () => { - await visit(page, "/"); - await page.getByPlaceholder("Northwind Labs").waitFor(); - }); + const ORG_1 = "Switcher Org One"; + const ORG_2 = "Switcher Org Two"; + const account = yield* target.newIdentity({ org: false }); + yield* addOrg(target, account, ORG_1); + const { org: org2, identity } = yield* addOrg(target, account, ORG_2); - const ORG_1 = "Switcher Org One"; - const ORG_2 = "Switcher Org Two"; - - await step(`Create "${ORG_1}" via onboarding`, async () => { - await page.getByPlaceholder("Northwind Labs").fill(ORG_1); - await page.getByRole("button", { name: "Create organization" }).click(); - // Onboarding step 2 — proves the first org was created. - await page.getByText("Connect your MCP client").waitFor(); - }); - - await step("Continue into the app", async () => { - await page.getByRole("button", { name: "Continue to app" }).click(); + yield* browser.session(identity, async ({ page, step }) => { + await step(`Open "${ORG_2}"`, async () => { + await visit(page, `/${org2.slug}`); await page.getByText("Integrations").first().waitFor(); + await page.getByRole("button", { name: new RegExp(ORG_2) }).waitFor(); // Let the router navigation fully settle before opening menus — a late - // remount closes them mid-interaction. The console canonicalizes onto - // the org's URL slug (/switcher-org-one). - await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { - timeout: 30_000, - }); + // remount closes them mid-interaction. await settle(page); }); - // ── Step 2: create the second org via the account-menu switcher ── - await step('Open the org switcher and choose "Create organization"', async () => { + // Capture the label while we are in ORG_2 as a baseline. + const labelInOrg2 = await page.getByRole("button", { name: new RegExp(ORG_2) }).innerText(); + expect(labelInOrg2, "account button shows the second org").toContain(ORG_2); + + const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); + + // ── The switcher offers v2 for new organizations, not v1 ───────── + await step("Open the org switcher: new organizations open Executor v2", async () => { // Bounded retry: under parallel-suite load the radix menu re-renders // while the org list loads and a click can land on a closing menu. for (let attempt = 1; ; attempt++) { try { await page.keyboard.press("Escape"); await page.getByRole("button", { name: /Test User/ }).click(); - await page.getByRole("menuitem", { name: ORG_1 }).click({ timeout: 5_000 }); - const subContent = page.locator('[data-slot="dropdown-menu-sub-content"]'); + await page.getByRole("menuitem", { name: ORG_2 }).click({ timeout: 5_000 }); await subContent.waitFor({ state: "visible", timeout: 5_000 }); - await subContent - .getByText("Create organization", { exact: true }) - .click({ timeout: 5_000 }); - await page.getByText("Add another organization").waitFor({ timeout: 5_000 }); + await subContent.getByRole("menuitem", { name: ORG_1 }).waitFor({ timeout: 5_000 }); break; } catch (error) { if (attempt >= 3) throw error; } } + const newOrgLink = subContent.getByRole("menuitem", { name: NEW_ORG_LINK }); + expect( + await newOrgLink.getAttribute("href"), + "the link is v1's create-org address, which the auth gate answers with v2's sign-up page", + ).toBe("/create-org"); + expect(await newOrgLink.getAttribute("target"), "it opens in a new tab").toBe("_blank"); + expect( + await subContent.getByText("Create organization", { exact: true }).count(), + "v1's create-organization dialog is gone", + ).toBe(0); }); - await step(`Create "${ORG_2}" via the org switcher modal`, async () => { - await page.getByPlaceholder("Northwind Labs").fill(ORG_2); - await page.getByRole("button", { name: "Create organization" }).click(); - // The modal closes and the session switches into the new org. - await page.getByText("Add another organization").waitFor({ state: "hidden" }); - // Confirm the account button now shows ORG_2. - await page.getByRole("button", { name: new RegExp(ORG_2) }).waitFor(); - }); - - // Capture the label while we are in ORG_2 as a baseline. - const labelAfterOrg2 = await page - .getByRole("button", { name: new RegExp(ORG_2) }) - .innerText(); - expect(labelAfterOrg2, "account button shows the second org after creation").toContain(ORG_2); - - // ── Step 3: switch back to the first org ───────────────────────── - // The org-switcher sub-menu shows org IDs (not names) because the stub's - // getOrganization returns the ID as the name. The currently-active org is - // rendered with data-disabled="" (Radix convention). The only item without - // data-disabled that isn't "Create organization" is ORG_1. - await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { - await settle(page); - await page.getByRole("button", { name: /Test User/ }).click(); - // Click the SubTrigger (shows current org name = ORG_2) to expand the list. - await page.getByRole("menuitem", { name: ORG_2 }).click(); - // Wait for the sub-content to open. - await page.locator('[data-slot="dropdown-menu-sub-content"]').waitFor({ state: "visible" }); - // The organizationsAtom loads asynchronously — wait until the loading state - // clears and the org items appear. The org items have data-disabled="" when - // active and no data-disabled when not. "Create organization" is always shown - // and always enabled; wait until there are at least 2 non-disabled items - // (the non-active org + "Create organization") before clicking. - await page - .locator('[data-slot="dropdown-menu-sub-content"]') - .locator('[role="menuitem"]:not([data-disabled])') - .nth(1) - .waitFor(); - // Now the sub-content has loaded. The org items appear BEFORE the separator and - // "Create organization". ORG_1 (non-active, not disabled) appears before ORG_2 - // (active, disabled) and before "Create organization". Click the first - // non-disabled item that is NOT "Create organization" — that is ORG_1. - await page - .locator('[data-slot="dropdown-menu-sub-content"]') - .locator('[role="menuitem"]:not([data-disabled])') - .filter({ hasNot: page.getByText("Create organization") }) - .first() - .click(); - // The menu closes, the page reloads, and the session switches into ORG_1. + // ── Switch to the first org ────────────────────────────────────── + await step(`Switch to "${ORG_1}" from the org switcher`, async () => { + await subContent.getByRole("menuitem", { name: ORG_1 }).click(); + // The page navigates to ORG_1's URL and the workspace re-scopes. await page.getByRole("button", { name: new RegExp(ORG_1) }).waitFor(); }); diff --git a/e2e/cloud/session-gate.test.ts b/e2e/cloud/session-gate.test.ts index 7868c872af..b165e27561 100644 --- a/e2e/cloud/session-gate.test.ts +++ b/e2e/cloud/session-gate.test.ts @@ -1,7 +1,8 @@ // Cloud-specific: the SSR auth gate's session handling BEYOND the basic // signed-out redirect (unauthenticated-skeleton.test.ts owns that): an // invalid cookie is actively cleared, /cloud (the marketing CTA path) routes -// into the app, and — the nastiest path — a session whose access token no +// into the app, a session with no organization is sent to Executor v2, and — +// the nastiest path — a session whose access token no // longer verifies is refreshed in-flight, with the rotated sealed session // reaching the browser. WorkOS refresh tokens are single-use, so losing that // Set-Cookie silently logs the user out on next expiry; these scenarios pin @@ -82,30 +83,65 @@ scenario( ); scenario( - "Session gate · an org-less session is sent to onboarding before the app shell exists", + "Session gate · an org-less session is sent to Executor v2 before the app shell exists", {}, Effect.gen(function* () { // Gate: the REST API plane is mounted on this target. yield* Api; const target = yield* Target; - // A verified user with no organization belongs in onboarding — the gate - // decides that at the edge, so the app shell (whose every query needs an - // org) is never even served. + // v1 creates no organizations. A verified user with none (and no pending + // invitation) is sent to v2's sign-up page, where organizations are + // created now — decided at the edge, so the app shell (whose every query + // needs an org) is never served. + const orgless = yield* target.newIdentity({ org: false }); + for (const path of ["/", "/tools", "/create-org"]) { + const gated = yield* documentRequest(new URL(path, target.baseUrl), orgless.headers!.cookie!); + expect(gated.status, `${path} is not served org-less`).toBe(302); + const location = new URL(gated.headers.get("location") ?? "", target.baseUrl); + expect(location.origin, `${path} leaves v1 for v2's host`).not.toBe( + new URL(target.baseUrl).origin, + ); + expect(location.pathname, `${path} opens v2's sign-in page`).toBe("/login"); + expect(location.searchParams.get("mode"), `${path} opens it to sign up`).toBe("signup"); + } + + // The join page (pending invitations) is served, not redirected — no loop. + const join = yield* documentRequest(new URL("/join", target.baseUrl), orgless.headers!.cookie!); + expect(join.status, "/join renders for the org-less session").toBe(200); + }), +); + +scenario( + "Session gate · /create-org opens Executor v2 for a member too, and nothing else moves", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + const member = yield* target.newIdentity(); + const cookie = member.headers!.cookie!; const orgless = yield* target.newIdentity({ org: false }); - const gated = yield* documentRequest( - new URL("/tools", target.baseUrl), - orgless.headers!.cookie!, - ); - expect(gated.status, "the app page is not served org-less").toBe(302); - expect(gated.headers.get("location"), "…onboarding owns this session").toBe("/create-org"); - // The onboarding page itself is served (no redirect loop). - const onboarding = yield* documentRequest( - new URL("/create-org", target.baseUrl), + // v1's create-an-organization address opens v2's sign-up page whatever + // organizations the session has — the same page an org-less session gets. + const createOrg = yield* documentRequest(new URL("/create-org", target.baseUrl), cookie); + const orglessRoot = yield* documentRequest( + new URL("/", target.baseUrl), orgless.headers!.cookie!, ); - expect(onboarding.status, "/create-org renders for the org-less session").toBe(200); + expect(createOrg.status, "/create-org redirects a member").toBe(302); + expect(createOrg.headers.get("location"), "…to the same v2 sign-up page").toBe( + orglessRoot.headers.get("location"), + ); + + // The member's own dashboard is served exactly as before. + const slug = member.headers!["x-executor-organization"]!; + for (const path of ["/", `/${slug}`, `/${slug}/policies`]) { + const page = yield* documentRequest(new URL(path, target.baseUrl), cookie); + expect(page.status, `${path} serves the member's app`).toBe(200); + } }), ); diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts index ef4c59ed18..482d447ecf 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -21,6 +21,7 @@ import { verifyAdmin } from "./admin-mfa"; import { Effect } from "effect"; import type { Identity, Target as TargetShape } from "../../src/target"; +import { seedOrganization, signIn } from "../../targets/cloud"; /** * The per-request org selector. `/api/auth/switch-organization` was removed @@ -44,7 +45,7 @@ export const cookieOf = (identity: Identity): string => identity.headers?.["cook /** * The identity re-bound so the BROWSER carries the same session the API calls - * do. `joinOrg` / `createOrg` refresh `headers.cookie` (the org is baked into + * do. `joinOrg` refreshes `headers.cookie` (the org is baked into * the sealed session) but leave `cookies` on the pre-change value, and the * browser context is seeded from `cookies`. */ @@ -140,16 +141,49 @@ export const joinOrg = ( return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); }); -/** Create another org for this account; returns the identity bound to it. */ -export const createOrg = ( +/** + * Give this account another organization, as its admin, and sign in to it. + * v1 creates no organizations, so it is seeded in WorkOS (`seedOrganization`); + * one sign-in mirrors it and mints its slug, and a second, returning to its + * URL, lands the session in it. Returns the org and the identity bound to it: + * a fresh session (the account's earlier sessions stay valid) and the org's + * selector. `name` must be unique among the account's organizations. + */ +export const addOrg = ( target: TargetShape, identity: Identity, name: string, -): Effect.Effect => - Effect.gen(function* () { - const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); - const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; - return withRefreshedSession(identity, response, created.id); +): Effect.Effect<{ + readonly org: { readonly id: string; readonly name: string; readonly slug: string }; + readonly identity: Identity; +}> => + Effect.promise(async () => { + const email = identity.credentials?.email; + if (!email) throw new Error(`identity ${identity.label} carries no email to sign in with`); + await seedOrganization(email, name); + const mirrored = await signIn(email); + const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), { + headers: { cookie: mirrored }, + }); + if (!response.ok) throw new Error(`/api/auth/organizations failed (${response.status})`); + const { organizations } = (await response.json()) as { + organizations: ReadonlyArray<{ id: string; name: string; slug: string }>; + }; + const named = organizations.filter((organization) => organization.name === name); + if (named.length !== 1) { + throw new Error(`expected one organization named "${name}", found ${named.length}`); + } + const org = named[0]!; + const session = await signIn(email, `/${org.slug}`); + const separator = session.indexOf("="); + return { + org, + identity: { + ...identity, + headers: { cookie: session, [ORG_SELECTOR_HEADER]: org.slug }, + cookies: [{ name: session.slice(0, separator), value: session.slice(separator + 1) }], + }, + }; }); /** This identity, scoped to `organizationId` for the requests it makes. */ @@ -170,21 +204,25 @@ export const accountIdOf = (target: TargetShape, identity: Identity): Effect.Eff return body.user.id; }); -/** The org this identity's session is currently bound to: id and URL slug. */ +/** The org this identity's session is currently bound to: id, URL slug and name. */ export const activeOrg = ( target: TargetShape, identity: Identity, -): Effect.Effect<{ readonly id: string; readonly slug: string }> => +): Effect.Effect<{ readonly id: string; readonly slug: string; readonly name: string }> => Effect.promise(async () => { const response = await fetch(new URL("/api/auth/me", target.baseUrl), { headers: { cookie: cookieOf(identity) }, }); if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); const body = (await response.json()) as { - organization: { id: string; slug: string } | null; + organization: { id: string; slug: string; name: string } | null; }; if (!body.organization) throw new Error("identity has no active organization"); - return { id: body.organization.id, slug: body.organization.slug }; + return { + id: body.organization.id, + slug: body.organization.slug, + name: body.organization.name, + }; }); /** Every org this account is a member of, plus the one the session is on. */ diff --git a/e2e/scripts/ci-shard-durations.ts b/e2e/scripts/ci-shard-durations.ts index d1a3f6ad6d..b8640c87c2 100644 --- a/e2e/scripts/ci-shard-durations.ts +++ b/e2e/scripts/ci-shard-durations.ts @@ -31,7 +31,6 @@ export const OBSERVED_TEST_DURATIONS_MS: Readonly< "cloud/org-api-keys-console.test.ts": 9_520, "cloud/org-delete.test.ts": 8_075, "cloud/org-last-visited.test.ts": 6_696, - "cloud/org-limit.test.ts": 19_139, "cloud/org-multitab-cookie.test.ts": 15_626, "cloud/org-switcher.test.ts": 7_686, "cloud/repro-transport-brick.test.ts": 5_474, diff --git a/e2e/scripts/record-connect-card.ts b/e2e/scripts/record-connect-card.ts index 0085740077..69f9661872 100644 --- a/e2e/scripts/record-connect-card.ts +++ b/e2e/scripts/record-connect-card.ts @@ -8,7 +8,9 @@ // // It mints a real signed-in session through the headless WorkOS-emulator login // (the same flow targets/cloud.ts uses), then screenshots just the command -// block so the URL is legible. +// block so the URL is legible. The account's organization is seeded in this +// checkout's WorkOS emulator, so run it against a cloud stack this checkout +// booted. // // Usage: bun e2e/scripts/record-connect-card.ts // writes -ssr.png (first paint) and -hydrated.png @@ -16,6 +18,7 @@ import { randomUUID } from "node:crypto"; import { chromium } from "playwright"; import { visit } from "../src/surfaces/browser"; +import { seedOrganization } from "../targets/cloud"; const [baseUrl, outPrefix] = process.argv.slice(2); if (!baseUrl || !outPrefix) { @@ -30,9 +33,11 @@ const cookiePair = (response: Response, name: string): string | undefined => { return undefined; }; -// The real product login, headless (login → hosted AuthKit → callback), then -// the real create-organization flow so the card renders an org-scoped URL. +// An organization in WorkOS with this account as its admin (v1 creates none), +// then the real product login, headless (login → hosted AuthKit → callback), +// which lands in it, so the card renders an org-scoped URL. const signInWithOrg = async (email: string): Promise => { + await seedOrganization(email, `Org ${email.split("@")[0]!}`); const login = await fetch(new URL("/api/auth/login", baseUrl), { redirect: "manual" }); const stateCookie = cookiePair(login, "wos-login-state"); const authorizeUrl = new URL(login.headers.get("location") ?? ""); @@ -47,21 +52,8 @@ const signInWithOrg = async (email: string): Promise => { redirect: "manual", headers: { cookie: stateCookie }, }); - let session = cookiePair(callback, "wos-session"); + const session = cookiePair(callback, "wos-session"); if (!session) throw new Error(`callback set no session (${callback.status})`); - - const label = email.split("@")[0]!; - const created = await fetch(new URL("/api/auth/create-organization", baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - origin: new URL(baseUrl).origin, - cookie: session, - }, - body: JSON.stringify({ name: `Org ${label}` }), - }); - if (!created.ok) throw new Error(`create-organization failed (${created.status})`); - session = cookiePair(created, "wos-session") ?? session; return session; // "wos-session=" }; diff --git a/e2e/src/target.ts b/e2e/src/target.ts index a410490537..7bc26a0f66 100644 --- a/e2e/src/target.ts +++ b/e2e/src/target.ts @@ -35,7 +35,7 @@ export interface Target { * Mint a fresh identity — THE isolation model: no resets, every scenario * is its own user (and org where applicable) on the shared instance. * `org: false` yields an identity with no active organization (for flows - * that create one, like onboarding / billing limits). + * that join one by invitation, or that have none). */ readonly newIdentity: (options?: { readonly org?: boolean }) => Effect.Effect; /** Headless OAuth consent for the MCP surface, when "mcp-oauth" is supported. */ diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts index b5d360a2ad..e20c1e5c3b 100644 --- a/e2e/targets/cloud.ts +++ b/e2e/targets/cloud.ts @@ -4,6 +4,11 @@ // flow: /api/auth/login → emulator hosted AuthKit (headless via login_hint) → // /api/auth/callback → genuine sealed-session cookie. Isolation: every // identity is a fresh user (and org) — no resets. +// +// v1 creates no organizations (new ones are created in Executor v2), so an +// identity's organization is seeded in WorkOS — the system of record v1 +// mirrors at sign-in — exactly as every existing v1 organization lives there, +// and the sign-in lands the session in it. import { randomUUID } from "node:crypto"; import { Effect } from "effect"; @@ -28,9 +33,29 @@ const cookiePair = (response: Response, name: string): string | undefined => { return undefined; }; -/** The real product login, headless: login → hosted AuthKit → callback. */ -const signIn = async (email: string): Promise => { - const login = await fetch(new URL("/api/auth/login", CLOUD_BASE_URL), { redirect: "manual" }); +/** The WorkOS emulator the app's own WorkOS client talks to. */ +const workosEmulator = () => + connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }); + +/** + * An organization named `name` in WorkOS with `email` (created if new) as its + * admin, through the emulator's seed. The app sees it at the user's next + * sign-in, which mirrors every organization the user belongs to. + */ +export const seedOrganization = async (email: string, name: string): Promise => { + const workos = await workosEmulator(); + await workos.seed({ organizations: [{ name, members: [email] }] }); +}; + +/** + * The real product login, headless: login → hosted AuthKit → callback. + * `returnTo` (an org's `/`) lands the session in that organization, as + * the login the dashboard sends a signed-out visitor through does. + */ +export const signIn = async (email: string, returnTo?: string): Promise => { + const loginUrl = new URL("/api/auth/login", CLOUD_BASE_URL); + if (returnTo !== undefined) loginUrl.searchParams.set("returnTo", returnTo); + const login = await fetch(loginUrl, { redirect: "manual" }); const stateCookie = cookiePair(login, "wos-login-state"); const location = login.headers.get("location"); if (!stateCookie || !location) { @@ -57,6 +82,17 @@ const signIn = async (email: string): Promise => { return session; // "wos-session=" }; +/** The URL slug of the organization this session is signed in to. */ +const sessionOrgSlug = async (session: string): Promise => { + const response = await fetch(new URL("/api/auth/me", CLOUD_BASE_URL), { + headers: { cookie: session }, + }); + if (!response.ok) throw new Error(`cloud newIdentity: /api/auth/me failed (${response.status})`); + const body = (await response.json()) as { organization: { slug: string } | null }; + if (!body.organization) throw new Error("cloud newIdentity: the session has no organization"); + return body.organization.slug; +}; + export const cloudTarget = (): Target => ({ name: "cloud", baseUrl: CLOUD_BASE_URL, @@ -66,35 +102,18 @@ export const cloudTarget = (): Target => ({ // scenarios can compress the lifecycle (the TtlControl service). setAccessTokenTtl: (seconds) => Effect.promise(async () => { - const workos = await connectEmulator({ - baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}`, - }); + const workos = await workosEmulator(); await workos.seed({ oauth: { default_access_token_ttl_seconds: seconds } }); }), newIdentity: ({ org = true } = {}) => Effect.promise(async (): Promise => { const label = `user-${randomUUID().slice(0, 8)}`; const email = `${label}@e2e.test`; - let session = await signIn(email); - let orgSlug: string | null = null; - if (org) { - // The real create-organization flow; the refreshed sealed session in - // the response carries the new org. - const response = await fetch(new URL("/api/auth/create-organization", CLOUD_BASE_URL), { - method: "POST", - headers: { - "content-type": "application/json", - origin: new URL(CLOUD_BASE_URL).origin, - cookie: session, - }, - body: JSON.stringify({ name: `Org ${label}` }), - }); - if (!response.ok) { - throw new Error(`cloud newIdentity: create-organization failed (${response.status})`); - } - session = cookiePair(response, "wos-session") ?? session; - orgSlug = ((await response.json()) as { slug?: string }).slug ?? null; - } + if (org) await seedOrganization(email, `Org ${label}`); + // The seeded organization is the user's only membership, so the + // sign-in lands the session in it. + const session = await signIn(email); + const orgSlug = org ? await sessionOrgSlug(session) : null; const [name, value] = session.split(/=(.*)/s); return { label: email,