// 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 (`/<org>/connect/<slug>`) 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<typeof api>; /** 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 }, ), ); }),);
// 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 (`/<org>/connect/<slug>`) 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<typeof api>;
/** 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 },
),
);
}),
);