330 lines · 14.8 KB · TypeScript
RawDownload
// Selfhost-only (browser): the admin Users page against the OTHER auth model.//// Cloud gates this plane on a WorkOS `admin` membership; self-host gates it on// a Better Auth `owner`/`admin` member. The console is shared, so the same page// must open for the instance owner and refuse an invited plain member — that// second role vocabulary is exactly what the cloud scenario cannot cover.//// Selfhost identities are shared across scenarios (one bootstrap admin, one// org), so this asserts "contains mine" rather than global counts, and every// resource it creates is prefixed.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 { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario";import { createInvitedIdentity } from "../targets/selfhost";import { Api, Browser, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const);type Client = HttpApiClient.ForApi<typeof api>; declare global {  interface Window {    // What the page handed to the clipboard through the non-secure-origin    // execCommand fallback, read back out of the browser context.    __e2eCopied?: Array<string>;  }} const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */const pingSpec = JSON.stringify({  openapi: "3.0.3",  info: { title: "Ping API", version: "1.0.0" },  paths: {    "/ping": {      get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },    },  },}); const registerIntegration = (client: Client, label = "admin-ui-sh") =>  Effect.gen(function* () {    const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`);    yield* 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" }] },          },        ],      },    });    return slug;  }); scenario(  "Admin · a self-hosted owner sees the workspace's users; an invited member is refused",  { timeout: 180_000 },  Effect.gen(function* () {    const target = yield* Target;    const browser = yield* Browser;    const { client: apiClient } = yield* Api;     // The bootstrap identity owns the instance; the invited one is a plain    // member, which is the Better Auth role the admin plane refuses.    const owner = yield* target.newIdentity();    const member = yield* Effect.promise(() =>      createInvitedIdentity(target.baseUrl, owner, {        role: "member",        emailPrefix: "admin-ui-member",      }),    );     const ownerClient = yield* apiClient(api, owner);    const memberClient = yield* apiClient(api, member);    const integration = yield* registerIntegration(ownerClient);    // A second, deliberately unconnected integration, so the member's detail is    // guaranteed to render at least one connect link to assert the shape of.    const availableIntegration = yield* registerIntegration(ownerClient, "admin-ui-sh-avail");    const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);     yield* Effect.ensuring(      Effect.gen(function* () {        // The member stores their own credential, so the owner's view has        // something to report that the owner's product view cannot see.        yield* memberClient.connections.create({          payload: {            owner: "user",            name: memberConnection,            integration,            template: TEMPLATE_API_KEY,            value: "member-personal-token",          },        });         yield* browser.session(owner, async ({ page, step }) => {          await step("Open Users from the sidebar as the instance owner", async () => {            await page.goto("/", { waitUntil: "networkidle" });            await page              .locator("nav")              .getByRole("link", { name: "Users" })              .first()              .click({ timeout: 30_000 });            await page              .locator("[data-slot='admin-users-table']")              .waitFor({ state: "visible", timeout: 30_000 });          });           await step("The invited member's connection is attributed to them", async () => {            // Selfhost shares one org across scenarios, so this asserts the            // member's own row exists — never a count of the whole instance.            const row = page              .locator("[data-slot='admin-user-row']")              .filter({                has: page.locator(`[data-integration='${integration}'][data-connected='true']`),              })              .first();            await row.waitFor({ state: "visible", timeout: 30_000 });             // Better Auth's identity, joined server-side. The join key is the            // member list's `userId` (the Better Auth user id the subject table            // records), NOT the organization `member` row id — so asserting the            // email this identity was actually minted with is what proves the            // id spaces line up on THIS host's auth model.            expect(              await row.locator("[data-slot='admin-user-name']").textContent(),              "the member's row leads with the email they signed up with",            ).toBe(member.credentials?.email);             await row.click();             const detail = page.getByRole("dialog");            await detail.waitFor({ state: "visible", timeout: 30_000 });            await detail              .getByText(memberConnection, { exact: true })              .waitFor({ state: "visible", timeout: 30_000 });          });           await step("The detail header copies the member's email and their id", async () => {            const detail = page.getByRole("dialog");            // The member's opaque id, from the supporting line the header shows            // beneath the email.            const externalId = await detail              .locator("[data-slot='admin-user-detail-id']")              .getAttribute("title");            expect(externalId, "the header carries the opaque principal id").not.toBe("");             // Self-host is served over plain HTTP — a NON-secure origin, where            // `navigator.clipboard` does not exist and the copy must go through            // the execCommand fallback (see @executor-js/react `lib/clipboard`).            // Record what that path selects, exactly as the api-keys copy test            // does, so this asserts the real self-host copy path rather than a            // secure-context one the deployment never takes.            await page.evaluate(() => {              Object.defineProperty(navigator, "clipboard", {                configurable: true,                get: () => undefined,              });              const copied: Array<string> = [];              window.__e2eCopied = copied;              const exec = document.execCommand?.bind(document);              document.execCommand = (command, ...rest) => {                if (String(command).toLowerCase() === "copy") {                  copied.push(window.getSelection ? String(window.getSelection()) : "");                }                return exec ? exec(command, ...rest) : false;              };            });             await detail.getByRole("button", { name: "Copy email" }).click();            await detail.getByRole("button", { name: "Copy ID" }).click();             const copied = await page.evaluate(() => window.__e2eCopied ?? []);            expect(copied, "the email button copies the member's address").toContain(              member.credentials?.email,            );            expect(copied, "and the id button copies their opaque id").toContain(externalId);          });           await step(            "The built-in integration is never offered as something to connect",            async () => {              // It is a static, credential-free source: there is no connect flow              // to send anyone to, so it is neither a slot nor a link. This is the              // reported bug — it used to appear under "not connected" with a              // copyable link to a route that would do nothing.              const detail = page.getByRole("dialog");              expect(                await detail.locator("[data-integration='executor']").count(),                "the built-in gets no slot in the detail sheet",              ).toBe(0);              expect(                await detail.getByText("/connect/executor", { exact: false }).count(),                "and no connect link is rendered for it",              ).toBe(0);            },          );           await step("A connect link is scoped to the org the admin is viewing", async () => {            // The invariant is a round trip, not a fixed shape: whatever org the            // console is currently scoped to is the org the link carries, so a            // recipient cannot be sent into a different workspace than the one            // the admin was looking at.            //            // Self-host DOES have an org segment — it canonicalizes onto a            // `default` org — so this host exercises the prefixed form just as            // cloud does. The org-free form is what a host that renders no            // segment at all would produce, and it is covered by the display            // unit tests rather than pinned to a host here.            //            // The SHAPE of every rendered link is what this step pins; that the            // link resolves is asserted by following one in the next step, and            // the multi-org "lands in the sending workspace" round trip is            // cloud's `connect-link-multi-org.test.ts` (it needs a second org,            // which self-host's single turnkey org cannot provide).            const detail = page.getByRole("dialog");            const origin = new URL(target.baseUrl).origin;            // The console's own org prefix, read from the URL the page settled            // on — "" when this host renders no segment.            const consolePath = new URL(page.url()).pathname;            const orgPrefix = consolePath.endsWith("/users")              ? consolePath.slice(0, -"/users".length)              : "";             // The integration nobody connected is the one whose link must be here.            await detail              .getByText(`${origin}${orgPrefix}/connect/${availableIntegration}`, { exact: true })              .waitFor({ state: "visible", timeout: 30_000 });             // And EVERY rendered link agrees with the console's own scope — one            // link pointing at another org would silently misroute a recipient.            const links = await detail              .locator("[data-slot='admin-user-connect-link']")              .allTextContents();            expect(links.length, "the owner sees at least one link to send").toBeGreaterThanOrEqual(              1,            );            for (const link of links) {              const parsed = new URL(link);              expect(parsed.origin, `absolute for this deployment: ${link}`).toBe(origin);              expect(                parsed.pathname.startsWith(`${orgPrefix}/connect/`),                `scoped to the console's own org (${orgPrefix || "<none>"}): ${link}`,              ).toBe(true);              expect(                parsed.pathname.slice(`${orgPrefix}/connect/`.length).split("/").length,                `exactly one slug after /connect/: ${link}`,              ).toBe(1);            }          });           await step("The rendered connect link actually resolves", async () => {            // Following the link the page just rendered. A rendered string            // proves the page composed a URL; it cannot distinguish a working            // link from a 404 — which an operator would only discover after            // sending it to someone.            const origin = new URL(target.baseUrl).origin;            const consolePath = new URL(page.url()).pathname;            const orgPrefix = consolePath.endsWith("/users")              ? consolePath.slice(0, -"/users".length)              : "";            await page.goto(`${origin}${orgPrefix}/connect/${availableIntegration}`, {              waitUntil: "domcontentloaded",            });            // It forwards into that integration's own detail route with the            // add-account handoff, still inside the console's own org.            await page.waitForURL(              (url) => url.pathname === `${orgPrefix}/integrations/${availableIntegration}`,              { timeout: 30_000 },            );            expect(              new URL(page.url()).searchParams.get("addAccount"),              "the link lands in the connect flow, not just on the page",            ).toBe("1");          });        });         yield* browser.session(member, async ({ page, step }) => {          await step("A plain member is not offered the section", async () => {            await page.goto("/", { waitUntil: "networkidle" });            await page              .locator("nav")              .getByRole("link", { name: "Integrations" })              .first()              .waitFor({ state: "visible", timeout: 30_000 });            expect(              await page.locator("nav").getByRole("link", { name: "Users" }).count(),              "a plain member is not shown a section that would only refuse them",            ).toBe(0);          });           await step("And reaching the URL directly is refused, not silently empty", async () => {            await page.goto("/users", { waitUntil: "networkidle" });            await page              .getByText("You don't have access to this workspace's users")              .waitFor({ state: "visible", timeout: 30_000 });            expect(              await page.locator("[data-slot='admin-users-table']").count(),              "the refusal replaces the table rather than rendering it empty",            ).toBe(0);          });        });      }),      Effect.all(        [          memberClient.connections            .remove({ params: { owner: "user", integration, name: memberConnection } })            .pipe(Effect.ignore),          ownerClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore),          ownerClient.openapi            .removeSpec({ params: { slug: availableIntegration } })            .pipe(Effect.ignore),        ],        { discard: true },      ),    );  }),);