// 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 member of TWO orgs, whose session defaults to org B, // follows an org-A-scoped link, and the credential must end up in org A. // // 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; /** The spec's title, which the console uses to name a saved connection * ("Personal Ping API"). */ 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 lands 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" }); // The deep link forwards into the integration detail route with the // add-account handoff — and it must keep ORG A's prefix through the // redirect. Landing on `/${orgB.slug}/...` here IS the bug. await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { timeout: 30_000, }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], "the connect flow stayed in the SENDING org, not the session default", ).toBe(orgA.slug); expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); }); await step("Complete the connection from that page", async () => { const dialog = page.getByRole("dialog"); await dialog.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 30_000, }); // The credential field is labelled by the method's PLACEMENT (the // `authorization` header this spec declares), not by the variable. // Waited for explicitly: the field renders only once the modal has // loaded the integration's auth methods, which is a second fetch // after the heading appears. const credential = dialog.getByRole("textbox", { name: "authorization" }); await credential.waitFor({ state: "visible", timeout: 90_000 }); await credential.fill("recipient-personal-token"); // The offered health check is opt-in (it runs only on "Check"), and // this spec's base URL is never served — so the credential is saved // unprobed, which is what this scenario is about: WHERE it lands, // not whether it works. await dialog.getByRole("button", { name: "Continue" }).click(); await dialog.getByRole("button", { name: "Add connection" }).click(); // The saved credential appears as a row in the accounts list of the // page it was saved from — and that page is ORG A's (its URL was // pinned to `orgA.slug` in the step above). So this row IS the // "landed in the sending workspace" half of the guarantee, read // from the surface the recipient is actually looking at. // // Waited on rather than the success toast (which auto-dismisses // below the fold) or the dialog's disappearance (which races the // close animation). 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 credential was saved from a page scoped to 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 renders; it simply must hold no connection. // Had the link resolved against the session default, THIS is the page // the credential would be on. await step("Org B — the session's own default — has none", async () => { await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { waitUntil: "domcontentloaded", }); // Wait for the accounts panel to finish loading before asserting an // absence, so "not rendered yet" cannot pass as "not there". The // empty state is the positive signal that the list resolved AND is // empty — checking only for the missing row would also pass while // the list was still loading. await page .getByRole("button", { name: "Add connection" }) .first() .waitFor({ state: "visible", timeout: 90_000 }); await page .getByText("No connections", { exact: false }) .first() .waitFor({ state: "visible", timeout: 90_000 }); expect( await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), "nothing landed in the org the recipient's session happened to default to", ).toBe(0); }); }); }), // Removing each org's spec takes its connections with it, so the // UI-created credential (whose name the console chose) needs no lookup. Effect.all( [ ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), ownerBClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), ], { discard: true }, ), ); }), );