460 lines · 21.0 KB · TypeScript
RawDownload
// Cloud-only: the admin Users PAGE — the console surface over `/api/admin/users*`.//// The sibling `admin-users.test.ts` pins the API's guarantees. This one pins// what an operator actually sees, which the API scenario cannot: that the page// renders every member of the workspace, that each row's connection summary// distinguishes connected from merely available, that opening a user shows that// user's own connections with their health, and — the failure path that matters// 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.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 { Api, Browser, Target } from "../src/services";import { accountIdOf, forBrowser, joinOrg } from "./support/session";import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const);type Client = HttpApiClient.ForApi<typeof api>; declare global {  interface Window {    // The copy assertions stash what the page handed to the clipboard here, so    // it can be read back out of the browser context without needing a    // clipboard-read permission a headless context won't grant.    __e2eCopied?: Array<string>;  }} const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");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" } } },    },  },}); const noProbeSpec = JSON.stringify({  openapi: "3.0.3",  info: { title: INTEGRATION_TITLE, version: "1.0.0" },  paths: {},}); /** Registers a fresh apiKey-authenticated integration for connections to bind to. */const registerIntegration = (client: Client, label: string, spec = pingSpec) =>  Effect.gen(function* () {    const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`);    yield* client.openapi.addSpec({      payload: {        spec: { kind: "blob", value: spec },        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;  }); const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); scenario(  "Admin · the Users page shows every member and what each has connected, and refuses a plain member",  { timeout: 180_000 },  Effect.gen(function* () {    const target = yield* Target;    const browser = yield* Browser;    const { client: apiClient } = yield* Api;     const admin = yield* target.newIdentity();    const invitee = yield* target.newIdentity({ org: false });    const member = yield* joinOrg(target, admin, invitee);     const adminClient = yield* apiClient(api, admin);    const memberClient = yield* apiClient(api, member);    const adminId = yield* accountIdOf(target, admin);    const memberId = yield* accountIdOf(target, member);     // Two integrations so the member's summary has a real zero-of-two state.    const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn");    const availableIntegration = yield* registerIntegration(      adminClient,      "admin-ui-avail",      noProbeSpec,    );    const adminConnection = freshConnectionName();    const memberConnection = freshConnectionName();    yield* Effect.ensuring(      Effect.gen(function* () {        // Both roles may store Personal credentials. A member cannot promote        // theirs into a Workspace credential by bypassing the owner picker.        yield* adminClient.connections.create({          payload: {            owner: "user",            name: adminConnection,            integration: connectedIntegration,            template: TEMPLATE_API_KEY,            value: "admin-personal-token",          },        });        const workspaceRefusal = yield* memberClient.connections          .create({            payload: {              owner: "org",              name: memberConnection,              integration: connectedIntegration,              template: TEMPLATE_API_KEY,              value: "member-personal-token",            },          })          .pipe(Effect.flip);        expect(workspaceRefusal).toMatchObject({ _tag: "OrgWriteDeniedError" });        yield* memberClient.connections.create({          payload: {            owner: "user",            name: memberConnection,            integration: connectedIntegration,            template: TEMPLATE_API_KEY,            value: "member-personal-token",          },        });         // ── The admin's view ────────────────────────────────────────────────        yield* browser.session(forBrowser(admin), async ({ page, step }) => {          let slug = "";           await step("Land in the workspace and canonicalize onto the org slug", async () => {            await visit(page, "/");            await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {              timeout: 30_000,            });            slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";            expect(slug, "the URL settled on an org slug").not.toBe("");          });           await step("Open Users from the sidebar", async () => {            // The section is admin-only, so an admin must actually see the link            // — reaching the page by URL would not prove the nav gate opened.            await page.getByRole("link", { name: "Users" }).click();            await page.waitForURL((url) => url.pathname === `/${slug}/users`, { timeout: 30_000 });            await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 });          });           await step("Both members of the workspace are listed", async () => {            const rows = page.locator("[data-slot='admin-user-row']");            await rows.first().waitFor({ timeout: 30_000 });            // The id is opaque and rendered shortened, so the row's full value            // lives in the title the operator hovers for.            const ids = await rows              .locator("[data-slot='admin-user-id']")              .evaluateAll((elements) =>                elements.map((element) => element.getAttribute("title") ?? ""),              );            expect(ids, "the admin's own row is listed").toContain(adminId);            expect(              ids,              "the invited member is listed too, though the admin's product view cannot see them",            ).toContain(memberId);             // The host joins identity server-side, so an operator reads people            // by name rather than by opaque id. The emails are the ones these            // identities were actually minted with — asserting the seeded values            // is what proves the join key lines up, since a wrong key would            // leave every row unnamed while the id assertions above still pass.            const names = await rows              .locator("[data-slot='admin-user-name']")              .evaluateAll((elements) => elements.map((element) => element.textContent ?? ""));            expect(names, "the admin's row leads with their email").toContain(              admin.credentials?.email,            );            expect(names, "and the invited member's with theirs").toContain(              member.credentials?.email,            );          });           await step("Each row summarizes connected vs available integrations", async () => {            const memberRow = page              .locator("[data-slot='admin-user-row']")              .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) });            await memberRow.waitFor({ state: "visible", timeout: 30_000 });            // A fresh cloud org's catalog is exactly the two integrations this            // scenario registered: the built-in Executor integration is not            // connectable, so it is neither a slot nor a denominator. The            // denominator is therefore pinned, not read — a third slot here            // would mean the built-in leaked back into the view.            const summary = memberRow.locator("[data-slot='admin-user-connection-count']");            await summary.waitFor({ state: "visible", timeout: 30_000 });            expect(              await summary.textContent(),              "one connectable integration is connected, with the built-in out of both numbers",            ).toBe("1/2");            expect(              await memberRow.locator("[data-integration='executor']").count(),              "the built-in integration has no connect flow, so it gets no slot",            ).toBe(0);            expect(              await memberRow                .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`)                .count(),              "the member's Personal credential is lit in their summary",            ).toBe(1);            expect(              await memberRow                .locator(`[data-integration='${availableIntegration}'][data-connected='false']`)                .count(),              "the one they haven't connected still has a slot — that absence is the view",            ).toBe(1);          });           await step("Open the member's detail and confirm their Personal connection", async () => {            await page              .locator("[data-slot='admin-user-row']")              .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) })              .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 });            expect(              await detail.getByLabel("Status: Unchecked").count(),              "a never-probed connection reads as unchecked, not healthy",            ).toBe(1);            // The other member's credential is not this member's business.            expect(              await detail.getByText(adminConnection, { exact: true }).count(),              "one user's detail never shows another user's connection",            ).toBe(0);          });           await step("The detail header copies the member's email and their id", async () => {            const detail = page.getByRole("dialog");            // Record what reaches the clipboard rather than reading it back:            // a headless context grants no clipboard-read permission, and the            // assertion is about what the button hands over, not about the            // browser's store.            await page.evaluate(() => {              const copied: Array<string> = [];              window.__e2eCopied = copied;              Object.defineProperty(navigator, "clipboard", {                configurable: true,                get: () => ({                  writeText: (text: string) => {                    copied.push(text);                    return Promise.resolve();                  },                }),              });            });             // Two buttons, not one: the email is what an operator pastes into a            // mail client, the id is what they paste into a log search.            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 address the member signed up with",            ).toContain(member.credentials?.email);            expect(copied, "and the id button copies the opaque principal id").toContain(memberId);          });           await step("The not-connected integration offers a copyable connect link", async () => {            const detail = page.getByRole("dialog");            const origin = new URL(target.baseUrl).origin;            // The link carries THIS admin's org slug. A recipient who belongs            // to several orgs would otherwise resolve the bare form against            // their default org and connect in the wrong workspace. That            // multi-org round trip is its own scenario            // (`connect-link-multi-org.test.ts`), which needs a second org to            // land in by mistake; here the concern is what this page RENDERS,            // plus that the rendered link actually resolves (below).            await detail              .getByText(`${origin}/${slug}/connect/${availableIntegration}`, { exact: true })              .waitFor({ state: "visible", timeout: 30_000 });            expect(              await detail                .getByText(`${origin}/connect/${availableIntegration}`, { exact: true })                .count(),              "the org-free form is never rendered on a host that has orgs",            ).toBe(0);            // Only the unconnected integration is available; the member's            // Personal connection consumes the other slot. The built-in still            // offers no link at all.            expect(              await detail.getByRole("button", { name: "Copy link" }).count(),              "one link for the not-connected integration, and none for the built-in",            ).toBe(1);            expect(              await detail.getByText("/connect/executor", { exact: false }).count(),              "the built-in integration is never offered as a connect link",            ).toBe(0);          });           await step("The rendered connect link actually resolves", async () => {            // Following the link the page just rendered, rather than only            // reading it back. A rendered string proves the page composed a URL;            // it cannot tell a working link from a 404 — which is the failure an            // operator would discover only after sending it to someone.            const origin = new URL(target.baseUrl).origin;            await page.goto(`${origin}/${slug}/connect/${availableIntegration}`, {              waitUntil: "domcontentloaded",            });            // It forwards into that integration's own detail route with the            // add-account handoff, still inside this admin's org.            await page.waitForURL(              (url) => url.pathname === `/${slug}/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");            const dialog = page.getByRole("dialog");            await dialog              .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false })              .waitFor({ state: "visible", timeout: 30_000 });            const credential = dialog.getByRole("textbox", { name: "authorization" });            await credential.waitFor({ state: "visible", timeout: 90_000 });            await credential.fill("admin-scope-proof-token");            await dialog.getByRole("button", { name: "Continue" }).click();            const owner = dialog.getByRole("combobox");            await owner.waitFor({ state: "visible", timeout: 30_000 });            expect(await owner.textContent(), "an admin is offered Personal scope").toContain(              "Personal",            );            await owner.click();            await page              .getByRole("option", { name: "Workspace", exact: true })              .waitFor({ state: "visible", timeout: 30_000 });            await page.keyboard.press("Escape");            await page.keyboard.press("Escape");          });        });         // ── The plain member's view ─────────────────────────────────────────        yield* browser.session(forBrowser(member), async ({ page, step }) => {          let slug = "";           await step("Sign in as a plain member of the same workspace", async () => {            await visit(page, "/");            await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {              timeout: 30_000,            });            slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";            expect(slug, "the member landed in the workspace").not.toBe("");          });           await step("The Users section is not offered to them", async () => {            await page              .getByRole("link", { name: "Integrations" })              .first()              .waitFor({ state: "visible", timeout: 30_000 });            expect(              await page.getByRole("link", { name: "Users" }).count(),              "a plain member is not shown a section that would only refuse them",            ).toBe(0);          });           await step(            "Reaching the URL directly says no access, not an empty workspace",            async () => {              await visit(page, `/${slug}/users`);              // The denial is the assertion: an empty table here would misreport              // a populated workspace as having no users.              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);              expect(                await page.getByText(adminId, { exact: true }).count(),                "no other member's identity leaks to a refused viewer",              ).toBe(0);            },          );           await step(            "The member can add Personal connections without a scope dropdown",            async () => {              await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`);              const add = page.getByRole("button", { name: "Add connection" });              await add.waitFor({ state: "visible", timeout: 30_000 });              await add.click();              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 rather than offered a scope picker",              ).toBe(0);              await page.keyboard.press("Escape");            },          );           await step("Their connect deep link opens the Personal add flow", async () => {            await visit(page, `/${slug}/connect/${availableIntegration}`);            await page.waitForURL(              (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`,              { timeout: 30_000 },            );            expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1");            await page              .getByRole("dialog")              .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false })              .waitFor({ state: "visible", timeout: 30_000 });          });        });      }),      Effect.all(        [          adminClient.connections            .remove({              params: { owner: "user", integration: connectedIntegration, name: adminConnection },            })            .pipe(Effect.ignore),          memberClient.connections            .remove({              params: { owner: "user", integration: connectedIntegration, name: memberConnection },            })            .pipe(Effect.ignore),          adminClient.openapi            .removeSpec({ params: { slug: connectedIntegration } })            .pipe(Effect.ignore),          adminClient.openapi            .removeSpec({ params: { slug: availableIntegration } })            .pipe(Effect.ignore),        ],        { discard: true },      ),    );  }),);