// Cloud-only: the connect deep link's REASON FOR CARRYING AN ORG SLUG. // // An operator on the admin Users page copies a connect link for a member who // hasn't connected something yet and sends it to them. `connectLinkUrl` builds // that link org-PREFIXED (`//connect/`) rather than bare, and the // failure that choice prevents is specific and silent: a recipient who belongs // to several workspaces has ONE session-default org, and a bare `/connect/x` // resolves against THAT one. They would land in the wrong workspace, connect a // real credential there, and nothing anywhere would report a problem — the // connection succeeds, it is just in the wrong tenant. The sender then keeps // seeing an unconnected user. // // The existing coverage cannot catch this. `scenarios/connect-deep-link.test.ts` // deliberately accepts EITHER URL form (it runs on hosts with and without an // org segment) and its user belongs to one org, so both forms resolve to the // same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves // the router parses the param. Neither has a second org to land in by mistake. // // So: a recipient who is a plain member of TWO orgs, whose session defaults to // org B, follows an org-A-scoped link. The request must open the forced-Personal // connection flow in org A — never enter a connection flow in B. // // Both orgs register an integration under the SAME slug — that is what makes // this a real test. With distinct slugs, org B's catalog would simply not // contain the link's slug and the page would show its not-found state, so the // scenario would pass for the wrong reason (a lookup miss rather than correct // scoping). Sharing the slug means BOTH orgs can satisfy the link, and only the // org prefix decides which one does. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/session"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ const pingSpec = JSON.stringify({ openapi: "3.0.3", info: { title: INTEGRATION_TITLE, version: "1.0.0" }, paths: { "/ping": { get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, }, }, }); /** Register an apiKey-authenticated integration under an EXACT slug, so both * orgs can hold the same catalog identity. */ const registerIntegration = (client: Client, slug: IntegrationSlug) => client.openapi.addSpec({ payload: { spec: { kind: "blob", value: pingSpec }, slug, baseUrl: "http://127.0.0.1:59999", // never contacted during registration authenticationTemplate: [ { slug: "apiKey", type: "apiKey", headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, }, ], }, }); scenario( "Connect · an org-scoped connect link resolves a multi-org recipient in the SENDING org", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const { client: apiClient } = yield* Api; // Two workspaces with two different owners, and one person invited into // both. Built through the real invite flow, so the memberships are the ones // WorkOS actually issues. const ownerA = yield* target.newIdentity(); const ownerB = yield* target.newIdentity(); const recipientSeed = yield* target.newIdentity({ org: false }); const joinedA = yield* joinOrg(target, ownerA, recipientSeed); // Accepting B's invitation SECOND re-seals the session onto org B, which is // exactly the hazard: the recipient's default is now the wrong workspace // for the link they are about to follow. const inOrgB = yield* joinOrg(target, ownerB, joinedA); // `joinedA`'s cookie was re-sealed by the SECOND accept, so it is stale and // must not be used to read anything — the recipient's live session is // `inOrgB`, pointed at whichever org a given read is about. const orgA = yield* activeOrg(target, ownerA); const orgB = yield* activeOrg(target, inOrgB); expect(orgA.id, "the two workspaces are genuinely different").not.toBe(orgB.id); const memberships = yield* organizationsOf(target, inOrgB); expect( memberships.organizations.map((org) => org.id).sort(), "the recipient really is a member of BOTH workspaces", ).toEqual([orgA.id, orgB.id].sort()); expect( memberships.activeOrganizationId, "and their session defaults to org B — the org the link does NOT point at", ).toBe(orgB.id); const ownerAClient = yield* apiClient(api, ownerA); const ownerBClient = yield* apiClient(api, ownerB); // ONE slug, registered in BOTH catalogs (see the header note). const slug = IntegrationSlug.make(`multiorg-${randomBytes(4).toString("hex")}`); yield* Effect.ensuring( Effect.gen(function* () { yield* registerIntegration(ownerAClient, slug); yield* registerIntegration(ownerBClient, slug); // The link an operator copies off org A's Users page. Built here the // same way the page builds it (origin + org prefix + slug); the page's // own rendering of it is asserted in `admin-users-console.test.ts`. const origin = new URL(target.baseUrl).origin; const connectLink = `${origin}/${orgA.slug}/connect/${slug}`; // The recipient follows the link with their org-B-default session. yield* browser.session(forBrowser(inOrgB), async ({ page, step }) => { await step("Follow the org-A connect link with an org-B session", async () => { // `domcontentloaded`, not `networkidle`: the console holds live // connections open, so idle is not a state this page reaches. The // real wait is the redirect assertion below. await page.goto(connectLink, { waitUntil: "domcontentloaded" }); // A plain member can add a Personal connection. The org-A prefix // still proves the link resolved against the SENDING workspace // rather than session org B. await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { timeout: 30_000, }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], "the Personal add flow is scoped to the SENDING org, not the session default", ).toBe(orgA.slug); expect( new URL(page.url()).searchParams.get("addAccount"), "the member enters the add-account route", ).toBe("1"); const dialog = page.getByRole("dialog"); await dialog .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) .waitFor({ state: "visible", timeout: 30_000 }); expect( await dialog.getByText("Workspace", { exact: true }).count(), "the member is forced to Personal scope", ).toBe(0); }); await step("Complete the Personal connection in org A", async () => { const dialog = page.getByRole("dialog"); const credential = dialog.getByRole("textbox", { name: "authorization" }); await credential.waitFor({ state: "visible", timeout: 90_000 }); await credential.fill("recipient-personal-token"); await dialog.getByRole("button", { name: "Continue" }).click(); await dialog.getByRole("button", { name: "Add connection" }).click(); await page .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }) .first() .waitFor({ state: "visible", timeout: 90_000 }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], "the submitted Personal connection stayed in the sending org", ).toBe(orgA.slug); }); // ── The other half: it is NOT in the session's default org ───────── // // Same person, same session, same integration slug — the only thing // that differs is the org in the URL. Org B registered the SAME slug, // so this page exists and must resolve an empty connection list. await step("Org B has no persisted connection", async () => { await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { waitUntil: "domcontentloaded", }); const add = page.getByRole("button", { name: "Add connection" }); await add.waitFor({ state: "visible", timeout: 90_000 }); await page .getByText("No connections", { exact: false }) .first() .waitFor({ state: "visible", timeout: 90_000 }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], "navigating explicitly to org B changes the active add-flow scope", ).toBe(orgB.slug); expect( await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), "the submitted connection did not land in the session-default org", ).toBe(0); }); }); }), // Remove the same-slug fixtures from both organizations. Effect.all( [ ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), ownerBClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), ], { discard: true }, ), ); }), );