// @vitest-environment jsdom import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; import { aiConnectionsApi } from "@/api/ai-connections"; import { queryKeys } from "@/lib/queryKeys"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return"; import { AppsConnect } from "./AppsConnect"; import { TooltipProvider } from "@/components/ui/tooltip"; vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" })); const listGalleryMock = vi.hoisted(() => vi.fn()); const experimentalMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock, get: async () => ({ defaultEnvironmentId: "local-env" }), getGeneral: async () => ({}), } })); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); const getConnectionMock = vi.hoisted(() => vi.fn()); const getConnectionInstallsMock = vi.hoisted(() => vi.fn()); const connectAppMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const openAggregatorTaskMock = vi.hoisted(() => vi.fn()); vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: openAggregatorTaskMock }) })); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); const mockSearch = vi.hoisted(() => ({ value: "" })); const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined })); const mockCompany = vi.hoisted(() => ({ value: { selectedCompanyId: "company-1" as string | undefined, selectedCompany: { id: "company-1", name: "Paperclip" } as { id: string; name: string } | null, }, })); const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!; const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github")!; const GITHUB_MANAGED = { ...GITHUB, ownershipAvailability: { ...GITHUB.ownershipAvailability, platform_shared: true, }, }; const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!; const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!; const ASANA_MANAGED = { ...ASANA, ownershipAvailability: { ...ASANA.ownershipAvailability, platform_shared: true }, }; const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!; const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!; const NEON = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "neon")!; const SUPERAGENT = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "superagent")!; const GAUGE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gauge")!; const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-calendar")!; const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!; const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!; const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!; vi.mock("@/api/tools", () => ({ toolsApi: { syncAggregatorApps: vi.fn().mockResolvedValue({ apps: [], sync: { status: "idle" }, discovery: { availability: "available" } }), listGallery: (companyId: string) => listGalleryMock(companyId), listApplications: (companyId: string) => listApplicationsMock(companyId), listConnections: (companyId: string) => listConnectionsMock(companyId), getConnection: (id: string) => getConnectionMock(id), getConnectionInstalls: (id: string) => getConnectionInstallsMock(id), connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input), startOAuth: (connectionId: string, input?: unknown) => startOAuthMock(connectionId, input), finishApp: (companyId: string, connectionId: string, input: unknown) => finishAppMock(companyId, connectionId, input), putConnectionInstalls: (connectionId: string, installs: unknown) => putConnectionInstallsMock(connectionId, installs), getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(), startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) => startCloudConnectorEnrollmentMock(companyId, label, returnTo), }, })); vi.mock("@/api/agents", () => ({ agentsApi: { list: (companyId: string) => listAgentsMock(companyId) }, })); vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: (target: string) => navigateTopLevelMock(target), })); vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate, useParams: () => mockParams, useSearchParams: () => [new URLSearchParams(mockSearch.value), vi.fn()], })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => mockCompany.value, })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), })); vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } 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 })); } function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { const setter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, "value", )?.set; setter?.call(textarea, value); textarea.dispatchEvent(new Event("input", { bubbles: true })); } function buttonByText(text: string): HTMLButtonElement | undefined { return Array.from(document.body.querySelectorAll("button")).find( (b) => b.textContent?.trim() === text, ) as HTMLButtonElement | undefined; } function buttonContaining(text: string): HTMLButtonElement | undefined { return Array.from(document.body.querySelectorAll("button")).find( (b) => b.textContent?.includes(text), ) as HTMLButtonElement | undefined; } function radioContaining(text: string): HTMLButtonElement | undefined { return Array.from(document.body.querySelectorAll('[role="radio"]')).find( (button) => button.textContent?.includes(text), ); } /** The wizard stepper's progress dots, one per step. */ function stepDots(): HTMLElement[] { return Array.from( document.body.querySelectorAll('[data-testid="wizard-step-dot"]'), ); } function stepDotCount(): number { return stepDots().length; } /** The step names printed under the dots, e.g. "Access · Sign in". */ function stepLabelsOnScreen(): string[] { const labelLine = document.body.querySelector('[data-testid="wizard-step-labels"]')?.textContent ?? ""; return labelLine.split("·").map((label) => label.trim()).filter(Boolean); } /** * Open the Advanced disclosure that now holds the identity and agent-reach * controls the Access step used to own (PAP-659 C0). Closed by default and * unmounted while closed, so a test that asserts on those controls has to open * it first — which is itself the assertion that they are one click away. */ async function openAccessAdvanced() { const change = Array.from(document.body.querySelectorAll("button")).find( (b) => b.textContent?.trim() === "Change" && b.getAttribute("aria-expanded") === "false", ); if (!change) return; await act(async () => { change.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } /** * PAP-659 deleted the Access step: picking a connector now opens its connect * screen directly, and identity/agent reach are a stated default with an * Advanced disclosure rather than a screen to pass. * * What remains is the one-click handoff the Access step used to perform for * automatic-OAuth definitions, which is now the primary action of the sign-in * screen itself. Everywhere else this is a no-op, so the call sites keep * reading as "get to the part this test is about". */ async function passAccessStep() { const onOAuthEntryScreen = Boolean( document.body.textContent?.includes("Paperclip will open") || document.body.textContent?.includes("Your connection is saved."), ); if (onOAuthEntryScreen) { const handoff = Array.from(document.body.querySelectorAll("button")).find((b) => { const label = b.textContent?.trim() ?? ""; return label.startsWith("Continue to") || label.startsWith("Finish with"); }); await act(async () => { handoff?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); return; } // The remote-MCP aggregator setup (arcade, composio, executor, zapier) keeps // its own Access step until PAP-659's no-auth pass replaces it. Recognise it // by its own stepper rather than by any control the shared flow also has. if (!stepLabelsOnScreen().includes("Access")) { await flushReact(); return; } const anyAgent = Array.from(document.body.querySelectorAll('[role="radio"]')) .find((option) => option.textContent?.includes("Any agent")); if (anyAgent) { await act(async () => { anyAgent.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } const submit = Array.from(document.body.querySelectorAll("button")).find((b) => { const label = b.textContent?.trim() ?? ""; return label === "Save and continue" || label === "Continue"; }); await act(async () => { submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } /** Submit the curated setup screen after Access for OAuth methods that also * expose customer-owned client details. Browser sign-in remains the default * when the optional client ID is left empty. */ async function submitCuratedOAuthSetup() { const submit = buttonByText("Continue to sign in"); // Automatic OAuth definitions hand off directly from Access. Definitions // with required provider configuration still render the setup submit. if (!submit) { await flushReact(); return; } await act(async () => { submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } async function gotoLinkFrame(container: HTMLDivElement, url: string) { const linkInput = Array.from( container.querySelectorAll("input"), ).find((i) => i.getAttribute("placeholder")?.startsWith("https://")); expect(linkInput).toBeTruthy(); await act(async () => setInputValue(linkInput!, url)); await flushReact(); await act(async () => { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await passAccessStep(); } function notionConnection(id: string, credentialPolicy: string, status: string) { return { id: id, applicationId: "app-notion", authKind: "oauth", credentialPolicy: credentialPolicy, status: status, config: { sourceTemplateKey: "notion" }, transportConfig: {}, }; } function notionApplication(status: string) { return { id: "app-notion", status: status, metadata: { sourceTemplateKey: "notion" }, }; } function oauthStartResponse(connectionId: string, provider: string, authorizationUrl: string) { return { connectionId: connectionId, provider: provider, authorizationUrl: authorizationUrl, expiresAt: "2099-01-01T00:00:00.000Z", }; } function activeConnectorEnrollment() { return { configured: true, status: "active", brokerBaseUrl: "https://my-staging.paperclip.app", instanceId: "inst-test", environment: "staging", origins: ["https://paperclip.example.test"], }; } function unconfiguredConnectorEnrollment() { return { configured: false, status: "not_configured", brokerBaseUrl: "https://my-staging.paperclip.app", instanceId: null, environment: "staging", origins: [], }; } function customerOwnedApp(app: typeof GMAIL) { return { ...app, methods: app.methods.filter((method) => !method.oauthStrategy), ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, }; } describe("AppsConnect — Connect with a link (M4 frame)", () => { let container: HTMLDivElement; let mountedRoot: Root | null; beforeEach(() => { vi.resetAllMocks(); experimentalMock.mockResolvedValue({ enableChatConnectors: false }); window.sessionStorage.clear(); mockCompany.value = { selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" }, }; mockSearch.value = ""; mockParams.appKey = undefined; container = document.createElement("div"); document.body.appendChild(container); mountedRoot = null; listGalleryMock.mockResolvedValue({ apps: [ ZAPIER, GITHUB, ], capabilities: { canCreateOrganizationGrant: true, organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }, }); listApplicationsMock.mockResolvedValue({ applications: [] }); listConnectionsMock.mockResolvedValue({ connections: [] }); getConnectionInstallsMock.mockResolvedValue({ installs: [{ targetType: "company", targetId: "company-1" }] }); startOAuthMock.mockResolvedValue(oauthStartResponse("conn-notion", "notion", "https://mcp.notion.com/authorize?state=resumed")); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); getCloudConnectorEnrollmentMock.mockResolvedValue(activeConnectorEnrollment()); startCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "pending", brokerBaseUrl: "https://my-staging.paperclip.app", instanceId: "inst-test", environment: "staging", origins: [], verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", }); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "example.com" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, }); listAgentsMock.mockResolvedValue([ { id: "agent-1", name: "Ada", title: "CTO", status: "active", icon: "Bot" }, { id: "agent-2", name: "Grace", title: "Engineer", status: "active", icon: "Code" }, ]); }); afterEach(async () => { if (mountedRoot) { await act(async () => mountedRoot?.unmount()); } document.body.removeChild(container); document.body.innerHTML = ""; vi.restoreAllMocks(); vi.clearAllMocks(); }); async function render(queryClient?: QueryClient, byoOnly = false, content?: ReactNode) { const root = createRoot(container); mountedRoot = root; const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render( {content ?? } , ); }); await flushReact(); await flushReact(); return root; } it("keeps Honcho setup blocked until the provider workspace is supplied", async () => { const honcho = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "honcho")!; experimentalMock.mockResolvedValue({ enableMemoryConnectors: true }); listGalleryMock.mockResolvedValue({ apps: [honcho] }); await render(undefined, false, ); const key = container.querySelector('input[type="password"]')!; const workspace = container.querySelector('input[aria-label="Honcho workspace"]')!; expect(workspace.compareDocumentPosition(key) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(container.textContent).toContain(honcho.agentInstructions!.text); expect(container.querySelector('[aria-label="Agent instructions"] [role="switch"]')?.getAttribute("aria-checked")).toBe("true"); await act(async () => setInputValue(key, "test-honcho-key")); expect(buttonByText("Connect")?.disabled).toBe(true); await act(async () => buttonByText("Connect")?.click()); expect(connectAppMock).not.toHaveBeenCalled(); await act(async () => setInputValue(workspace, "paperclip-acme")); expect(buttonByText("Connect")?.disabled).toBe(false); await act(async () => buttonByText("Connect")?.click()); await flushReact(); expect(connectAppMock).toHaveBeenCalledOnce(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ configValues: { workspaceId: "paperclip-acme" }, agentInstructions: expect.objectContaining({ enabled: true, text: honcho.agentInstructions!.text }), })); }); it.each([false, true])("shows optional instructions on the Notion OAuth screen only when supplied (%s)", async (provided) => { const template = { id: "notion.test", version: 1, text: "Look up published decisions in Notion before proposing changes." }; listGalleryMock.mockResolvedValue({ apps: [{ ...NOTION, agentInstructions: provided ? template : undefined }] }); await render(undefined, false, ); expect(buttonByText("Continue to Notion")).toBeDefined(); const toggle = container.querySelector('[aria-label="Agent instructions"] [role="switch"]'); expect(container.textContent?.includes("Tell agents to use Notion")).toBe(provided); if (provided) { expect(toggle?.getAttribute("aria-checked")).toBe("true"); expect(container.textContent).toContain(template.text); } else expect(toggle).toBeNull(); expect(connectAppMock).not.toHaveBeenCalled(); }); it.each(["zapier", "arcade", "composio", "executor"])("inline aggregator %s collects the endpoint and completes only for the requester", async (provider) => { const onComplete = vi.fn(); const popup = vi.spyOn(window, "open").mockReturnValue(null); const connection = { id: "conn-inline", status: "draft", credentialPolicy: "per_user", authKind: "api_key" }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [{ id: "tool-1", status: "active" }, { id: "tool-2", status: "active" }], actions: { readOnly: [{ catalogEntryId: "tool-1", riskLevel: "read" }], canMakeChanges: [{ catalogEntryId: "tool-2", riskLevel: "write" }], }, suggestedDefaults: { askFirstRiskLevels: ["write", "destructive", "high", "critical"] }, }); await render(undefined, false, ); // PAP-659 C0: the gateway connectors reach the endpoint field immediately. // The task-scoped reach is stated rather than confirmed on its own step. const connectLabel = `Connect ${provider[0].toUpperCase()}${provider.slice(1)}`; expect(container.textContent).toContain("available to the agent that asked for it"); expect(radioContaining("Any agent")).toBeUndefined(); if (provider === "composio") { expect(container.textContent).not.toContain("MCP server URL"); expect(buttonByText("Reuse an existing session")?.getAttribute("aria-expanded")).toBe("false"); expect(buttonByText(connectLabel)?.disabled).toBe(false); await act(async () => buttonByText("Reuse an existing session")!.click()); } expect(container.textContent).toContain("MCP server URL"); expect(container.textContent).not.toContain("Add your key"); expect(container.textContent).not.toContain("Step 1 of 2"); const url = container.querySelector('input[type="password"]')!; expect(url.value).toBe(provider === "composio" ? "https://connect.composio.dev/mcp" : ""); expect(buttonByText(connectLabel)?.disabled).toBe(provider !== "composio"); await act(async () => setInputValue(url, "not a url")); await act(async () => buttonByText(connectLabel)!.click()); expect(container.textContent).toContain("Enter a valid MCP URL"); expect(connectAppMock).not.toHaveBeenCalled(); await act(async () => setInputValue(url, "https://provider.example/mcp")); // The sign-in method shares the one Advanced disclosure with the access // controls, and Radix unmounts collapsed content — so it has to be opened. expect(container.querySelector("select")).toBeNull(); await act(async () => buttonByText("Change")!.click()); expect(buttonByText("Advanced")).toBeUndefined(); expect(buttonByText("Change")?.getAttribute("aria-expanded")).toBe("true"); await act(async () => { const auth = container.querySelector("select")!; auth.value = "bearer"; auth.dispatchEvent(new Event("change", { bubbles: true })); }); const token = container.querySelector('input[id$="-token"]')!; await act(async () => setInputValue(token, "fixture-token")); await act(async () => buttonByText("Try again")!.click()); await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: connection.id })); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: provider, link: "https://provider.example/mcp", grantKind: "user", authMode: "bearer", credentialValues: { "credentials.authorization": "fixture-token" } })); // PAP-659 C6a/C7: this path used to send an empty ask-first list, so the // gateway connectors were the one place the armed write gate did not apply. expect(finishAppMock).toHaveBeenCalledWith("company-1", connection.id, { enabledCatalogEntryIds: ["tool-1", "tool-2"], askFirstCatalogEntryIds: ["tool-2"], access: { agentIds: ["agent-1"] }, preserveExistingAccess: true }); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); expect(popup).not.toHaveBeenCalled(); }); it.each(["arcade", "composio", "executor"])("inline aggregator %s binds OAuth to the task and retries the same draft", async (provider) => { const onComplete = vi.fn(); const onPhaseChange = vi.fn(); const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); const connection = { id: "conn-inline-oauth", status: "draft", credentialPolicy: "per_user", authKind: "oauth" }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); const root = await render(undefined, false, ); const connectLabel = `Connect ${provider[0].toUpperCase()}${provider.slice(1)}`; if (provider === "composio") await act(async () => buttonByText("Reuse an existing session")!.click()); await act(async () => setInputValue(container.querySelector('input[type="password"]')!, "https://provider.example/mcp")); await act(async () => buttonByText(connectLabel)!.click()); await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" })); expect(popup.location.assign).toHaveBeenCalled(); popup.closed = true; expect(container.textContent).toContain("Paperclip is waiting for confirmation"); expect(container.querySelector('a[target="_blank"]')?.getAttribute("href")).toBe("https://mcp.notion.com/authorize?state=resumed"); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); const message = (origin: string, interactionId: string, outcome: string) => window.dispatchEvent(new MessageEvent("message", { origin, data: { type: "paperclip.connection-intent.oauth", interactionId, outcome } })); await act(async () => { message("https://untrusted.example", "intent-inline", "connected"); message(window.location.origin, "other-intent", "connected"); }); expect(onComplete).not.toHaveBeenCalled(); await act(async () => message(window.location.origin, "intent-inline", "failed")); expect(container.textContent).toContain("Authorization did not complete"); await act(async () => buttonByText("Try again")!.click()); await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledTimes(2)); expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connection.id })); await act(async () => message(window.location.origin, "intent-inline", "connected")); expect(onComplete).toHaveBeenCalledWith({ resolvedByCallback: true }); await act(async () => root.unmount()); mountedRoot = null; expect(popup.close).toHaveBeenCalled(); }); it.each(["blocked", "throws", "closed"])("keeps inline Composio sign-in recoverable when its popup is %s", async (failure) => { const popup = { closed: true, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; const open = vi.spyOn(window, "open").mockImplementation(() => { if (failure === "throws") throw new Error("Browser blocked the popup"); return failure === "closed" ? popup as unknown as Window : null; }); const onPhaseChange = vi.fn(); const onComplete = vi.fn(); const connection = { id: "conn-composio", status: "draft", credentialPolicy: "per_user" }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); await render(undefined, false, ); await act(async () => buttonByText("Connect Composio")!.click()); await vi.waitFor(() => expect(container.textContent).toContain("Paperclip couldn’t open sign-in")); const link = container.querySelector('a[target="_blank"]')!; expect(link.href).toBe("https://mcp.notion.com/authorize?state=resumed"); expect(link.rel).toBe("noopener noreferrer"); expect(onPhaseChange).toHaveBeenCalledWith("needs_retry"); const click = new MouseEvent("click", { bubbles: true, cancelable: true }); await act(async () => link.dispatchEvent(click)); expect(click.defaultPrevented).toBe(false); expect(onPhaseChange).toHaveBeenLastCalledWith("authorizing"); expect(container.textContent).not.toContain("Paperclip couldn’t open sign-in"); expect(open).toHaveBeenCalledTimes(1); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(startOAuthMock).toHaveBeenCalledTimes(1); expect(onComplete).not.toHaveBeenCalled(); }); it.each(["arcade", "composio", "executor"])("keeps a native sign-in link when direct %s navigation is blocked", async (provider) => { mockSearch.value = `source=${provider}`; const connection = { id: "conn-provider", status: "draft", credentialPolicy: "shared" }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); navigateTopLevelMock.mockImplementationOnce(() => { throw new Error("Browser refused navigation"); }); await render(); if (provider !== "composio") { await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://provider.example/mcp")); } await act(async () => buttonByText(`Connect ${provider[0].toUpperCase()}${provider.slice(1)}`)!.click()); await vi.waitFor(() => expect(container.textContent).toContain("Paperclip couldn’t open sign-in")); const link = container.querySelector('a[target="_blank"]')!; expect(link.textContent).toBe("open sign-in again"); expect(link.href).toBe("https://mcp.notion.com/authorize?state=resumed"); expect(link.rel).toBe("noopener noreferrer"); const click = new MouseEvent("click", { bubbles: true, cancelable: true }); await act(async () => link.dispatchEvent(click)); expect(click.defaultPrevented).toBe(false); expect(navigateTopLevelMock).toHaveBeenCalledTimes(1); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(startOAuthMock).toHaveBeenCalledTimes(1); expect(finishAppMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("Waiting for sign-in"); }); it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => { const onCancel = vi.fn(); const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] }); getConnectionMock.mockResolvedValue(connection); const content = ; const root = await render(undefined, false, content); await act(async () => setInputValue(container.querySelector('input[type="password"]')!, "https://provider.example/mcp?token=fixture-secret")); await act(async () => buttonByText("Save & exit")!.click()); await vi.waitFor(() => expect(onCancel).toHaveBeenCalled()); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ saveDraft: true })); expect(sessionStorage.getItem("paperclip:mcp-intent-draft:company-1:intent-inline")).toBe(connection.id); expect(JSON.stringify(sessionStorage)).not.toContain("fixture-secret"); expect(finishAppMock).not.toHaveBeenCalled(); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); await act(async () => root.unmount()); mountedRoot = null; await render(undefined, false, content); await vi.waitFor(() => expect(container.textContent).toContain("MCP server URL")); expect(getConnectionMock).toHaveBeenCalledWith(connection.id); // Resuming lands on the same single screen it was saved from, not "step 2". expect(container.textContent).not.toContain("Step 2 of 2"); }); it.each(["page", "dialog"] as const)("lets %s setup cancel after an invalid provider URL without trying to save it", async (host) => { const onCancel = host === "dialog" ? vi.fn() : undefined; connectAppMock.mockRejectedValue(new Error("That connection URL does not belong to Zapier")); await render(undefined, false, ); await passAccessStep(); await act(async () => setInputValue(container.querySelector('input[type="password"]')!, "https://wrong-provider.example/mcp")); await act(async () => buttonByText("Connect Zapier")!.click()); await vi.waitFor(() => expect(container.textContent).toContain("That connection URL does not belong to Zapier")); await act(async () => buttonByText("Cancel")!.click()); expect(connectAppMock).toHaveBeenCalledTimes(1); if (onCancel) expect(onCancel).toHaveBeenCalledOnce(); else expect(mockNavigate).toHaveBeenCalledWith("/apps"); }); it("inline aggregator reuses an eligible account without changing its access", async () => { const onUseExisting = vi.fn().mockResolvedValue(undefined); await render(undefined, false, ); await act(async () => buttonContaining("Existing Composio")!.click()); expect(onUseExisting).toHaveBeenCalledWith("existing"); expect(connectAppMock).not.toHaveBeenCalled(); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); }); it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("blocks direct %s setup while memory connectors are off", async (provider) => { mockSearch.value = `source=${provider}`; await render(); expect(container.textContent).toContain("Enable memory connectors"); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("allows an existing memory connection to reconnect while setup is hidden", async () => { mockSearch.value = "source=mem0&reconnect=existing-memory"; getConnectionMock.mockResolvedValue({ id: "existing-memory", status: "active", config: { sourceTemplateKey: "mem0" } }); await render(); expect(getConnectionMock).toHaveBeenCalledWith("existing-memory"); expect(container.textContent).not.toContain("Enable memory connectors"); }); it.each(["zapier", "arcade", "composio", "executor"])("opens direct %s setup with default settings", async (provider) => { mockSearch.value = `source=${provider}`; await render(); expect(container.textContent).not.toContain("Enable MCP aggregators"); await passAccessStep(); expect(container.textContent?.includes("MCP server URL")).toBe(provider !== "composio"); expect(buttonByText("Advanced")).toBeUndefined(); expect(buttonByText("Change")?.getAttribute("aria-expanded")).toBe("false"); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("keeps an existing Composio session URL when its disclosure is closed", async () => { mockSearch.value = "source=composio&new=1"; await render(); expect(container.querySelector('input[id$="-url"]')).toBeNull(); await act(async () => buttonByText("Reuse an existing session")!.click()); await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://session.example/mcp")); await act(async () => buttonByText("Reuse an existing session")!.click()); expect(container.querySelector('input[id$="-url"]')).toBeNull(); await act(async () => buttonByText("Connect Composio")!.click()); await vi.waitFor(() => expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ link: "https://session.example/mcp" }))); }); it("reuses saved Composio accounts for a targeted app setup URL", async () => { mockSearch.value = "source=composio&targetToolkit=circleback_mcp"; listConnectionsMock.mockResolvedValue({ connections: [{ id: "saved-composio", status: "active", enabled: true, transport: "mcp_remote", config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }] }); await render(); await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps?source=composio&targetToolkit=circleback_mcp", { replace: true })); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("retains explicit new-account setup even when Composio is saved", async () => { mockSearch.value = "source=composio&targetToolkit=circleback_mcp&new=1"; listConnectionsMock.mockResolvedValue({ connections: [{ id: "saved-composio", status: "active", enabled: true, transport: "mcp_remote", config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }] }); await render(); expect(mockNavigate).not.toHaveBeenCalled(); expect(container.textContent).toContain("Connect Circleback through Composio"); }); it.each(["arcade", "composio"])("retains the selected upstream app in %s setup without claiming app authorization", async (provider) => { mockSearch.value = `source=${provider}&targetToolkit=hubspot`; await render(); expect(container.textContent).toContain(`Connect HubSpot through ${provider === "arcade" ? "Arcade" : "Composio"}`); expect(container.textContent).toContain(provider === "composio" ? "sign in to the app in Composio" : "Manage app sign-in in the provider"); expect(connectAppMock).not.toHaveBeenCalled(); }); it("returns completed Arcade gateway setup to Apps without creating an agent task", async () => { mockSearch.value = "source=arcade&targetToolkit=hubspot"; connectAppMock.mockResolvedValue({ connectionId: "new-arcade", connection: { id: "new-arcade", status: "active", credentialPolicy: "shared" }, catalog: [] }); await render(); await passAccessStep(); await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://api.arcade.dev/mcp/test")); await act(async () => buttonByText("Connect Arcade")!.click()); await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps")); expect(openAggregatorTaskMock).not.toHaveBeenCalled(); }); it.each(["source=composio", "source=composio&targetToolkit=hubspot"])("opens Composio permissions after gateway setup for %s", async (search) => { mockSearch.value = search; connectAppMock.mockResolvedValue({ connectionId: "new-composio", connection: { id: "new-composio", status: "active", credentialPolicy: "shared" }, catalog: [] }); await render(); await act(async () => buttonByText("Connect Composio")!.click()); await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps/new-composio/permissions")); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ grantKind: "organization" })); expect(putConnectionInstallsMock).toHaveBeenCalledWith("new-composio", [{ targetType: "company", targetId: "company-1" }]); expect(finishAppMock).toHaveBeenCalledWith("company-1", "new-composio", expect.objectContaining({ access: "all_agents" })); expect(openAggregatorTaskMock).not.toHaveBeenCalled(); expect(window.sessionStorage.getItem("paperclip:mcp-upstream-app:company-1:new-composio")).toBeNull(); }); it("recovers upstream app context after a gateway OAuth redirect", async () => { window.sessionStorage.setItem("paperclip:mcp-upstream-app:company-1:composio-draft", "hubspot"); mockSearch.value = "source=composio&resume=composio-draft&oauth=connected"; getConnectionMock.mockResolvedValue({ id: "composio-draft", status: "draft", credentialPolicy: "per_user", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" } }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Connect HubSpot through Composio")); expect(openAggregatorTaskMock).not.toHaveBeenCalled(); }); it.each(["arcade", "composio", "executor"])("explains a failed %s OAuth return and retries the same saved draft", async (provider) => { const draft = { id: "conn-oauth-draft", companyId: "company-1", status: "draft", authKind: "oauth", credentialPolicy: "shared", config: { sourceTemplateKey: provider, connectionMethodKey: "mcp", url: "https://example.com/mcp" } }; mockSearch.value = `source=${provider}&resume=${draft.id}&oauth=failed&code=oauth_callback_failed&error_description=untrusted-provider-message`; getConnectionMock.mockResolvedValue(draft); connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } }); await render(); await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Authorization did not complete")); expect(container.querySelector('input[id$="-url"]')?.value).toBe("https://example.com/mcp"); expect(container.textContent).not.toContain("untrusted-provider-message"); expect(buttonByText("Try again")).toBeTruthy(); await act(async () => buttonByText("Try again")!.click()); await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(draft.id, { asCurrentUser: false })); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: draft.id })); expect(navigateTopLevelMock).toHaveBeenCalled(); }); it("explains a declined Composio OAuth return without discarding the draft", async () => { mockSearch.value = "source=composio&resume=conn-oauth-draft&oauth=denied&code=oauth_authorization_denied"; getConnectionMock.mockResolvedValue({ id: "conn-oauth-draft", status: "draft", authKind: "oauth", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Connection cancelled. Your setup details are preserved")); expect(buttonByText("Try again")).toBeTruthy(); expect(connectAppMock).not.toHaveBeenCalled(); }); it.each([ ["shared", "Connects for everyone in your organization", "Any human in the organization", false], ["per_user", "Connects as you", "Just me", true], ])("retains the %s identity across an OAuth return without a step to walk back through", async (credentialPolicy, statedDefault, identityLabel, asCurrentUser) => { const draft = { id: "conn-oauth-draft", status: "draft", authKind: "oauth", credentialPolicy, config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } }; mockSearch.value = `source=composio&resume=${draft.id}&oauth=denied`; getConnectionMock.mockResolvedValue(draft); connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } }); await render(); // PAP-659 C0: the retained identity is stated on the one screen instead of // being re-confirmed on an Access step the operator has to navigate back to. await vi.waitFor(() => expect(container.textContent).toContain(statedDefault)); // It is also still editable, one disclosure away. Radix unmounts collapsed // content, so the controls have to be opened to be asserted at all. await act(async () => buttonByText("Change")!.click()); expect(container.textContent).toContain(identityLabel); expect(container.querySelector('[role="radiogroup"][aria-label="Which humans can use this credential?"]')).toBeNull(); await act(async () => buttonByText("Try again")!.click()); await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(draft.id, { asCurrentUser })); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: draft.id, grantKind: asCurrentUser ? "user" : "organization" })); }); it("shows only MCP URL setup on the BYO page", async () => { await render(undefined, true); const text = container.textContent ?? ""; expect(text).toContain("Connect your own MCP server"); expect(text).not.toContain("More ways to connect"); expect(text).not.toContain("Run your own"); expect(text).not.toContain("Paste a config"); expect(text).not.toContain("Search apps…"); expect(text).not.toContain("Pick the app you want your agents to use."); expect(text).not.toContain("Zapier"); const urlInput = container.querySelector('input[aria-label="MCP server URL"]'); expect(urlInput).toBeTruthy(); expect(document.activeElement).toBe(urlInput); }); it.each(["config-url", "transport-url", "transport-serverUrl"] as const)("reloads a generic task reconnect endpoint from %s with selection-only card metadata", async (source) => { const endpoint = "https://archive.example.test/mcp"; const choice = { id: "conn-archive", applicationId: "app-archive", name: "Archive", status: "active" as const, enabled: true }; listApplicationsMock.mockResolvedValue({ applications: [{ id: choice.applicationId, name: "Archive", applicationKey: "archive", type: "mcp_http" }] }); listConnectionsMock.mockResolvedValue({ connections: [{ ...choice, companyId: "company-1", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", credentialSource: "paperclip_vault", config: source === "config-url" ? { url: endpoint } : {}, transportConfig: source === "transport-url" ? { url: endpoint } : source === "transport-serverUrl" ? { serverUrl: endpoint } : {}, }] }); getConnectionMock.mockImplementation(async () => (await listConnectionsMock()).connections[0]); await render(undefined, false, ); const input = container.querySelector('input[aria-label="MCP server URL"]'); expect(input?.value).toBe(endpoint); expect(listConnectionsMock).toHaveBeenCalledWith("company-1"); await act(async () => { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await passAccessStep(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ link: endpoint, reconnectConnectionId: choice.id, })); }); it("preserves an edited task reconnect endpoint after refreshing connection data", async () => { const choice = { id: "conn-archive", applicationId: "app-archive", name: "Archive", status: "active" as const, enabled: true }; const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); listApplicationsMock.mockResolvedValue({ applications: [{ id: choice.applicationId, name: "Archive", applicationKey: "archive", type: "mcp_http" }] }); listConnectionsMock.mockResolvedValue({ connections: [{ ...choice, companyId: "company-1", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", credentialSource: "paperclip_vault", config: { url: "https://archive.example.test/mcp" } }] }); getConnectionMock.mockImplementation(async () => (await listConnectionsMock()).connections[0]); await render(client, false, ); const input = container.querySelector('input[aria-label="MCP server URL"]'); expect(input?.value).toBe("https://archive.example.test/mcp"); await act(async () => setInputValue(input!, "https://edited.example.test/mcp")); listConnectionsMock.mockResolvedValue({ connections: [{ ...choice, companyId: "company-1", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", credentialSource: "paperclip_vault", config: { url: "https://refreshed.example.test/mcp" } }] }); await act(async () => { await client.invalidateQueries(); }); await flushReact(); expect(container.querySelector('input[aria-label="MCP server URL"]')?.value).toBe("https://edited.example.test/mcp"); }); it("an unrecognized URL routes to a minimal frame that asks nothing but the address", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).toContain("https://www.example.com/actions"); // PAP-659 bucket H: the server's probe answers "does it need a key?", so the // operator is no longer asked to guess at it before anything has been tried. expect(container.textContent).not.toContain("Does it need a key?"); expect(buttonByText("No")).toBeUndefined(); expect(buttonByText("Yes")).toBeUndefined(); expect( Array.from(container.querySelectorAll("input")).some((i) => i.type === "password"), ).toBe(false); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); }); it("opens the shared Access step before configuring a pasted MCP server", async () => { await render(); const linkInput = Array.from( container.querySelectorAll("input"), ).find((input) => input.getAttribute("placeholder")?.startsWith("https://")); await act(async () => setInputValue(linkInput!, "https://mcp.example.com/actions")); await flushReact(); await act(async () => { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Which agents can use this connection?"); expect(radioContaining("Just me")).toBeTruthy(); expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Just agents I pick")).toBeTruthy(); expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true"); }); it.each([false, true])("gates chat-only cards in the embedded tool gallery without hiding GitHub (%s)", async (enabled) => { experimentalMock.mockResolvedValue({ enableChatConnectors: enabled }); listGalleryMock.mockResolvedValue({ apps: [GITHUB, getAppStoreDefinition("discord"), getAppStoreDefinition("telegram")] }); await render(); expect(container.textContent).toContain("GitHub"); expect(container.textContent?.includes("Discord")).toBe(enabled); expect(container.textContent?.includes("Telegram")).toBe(enabled); expect(container.textContent).not.toContain("Chat with an agent"); }); it.each([ ["telegram", "Telegram", "https://t.me/example_bot"], ["discord", "Discord", "https://discord.com/channels/example"], ])("does not reveal hidden %s setup from a pasted link or its generic follow-up", async (slug, name, url) => { listGalleryMock.mockResolvedValue({ apps: [GITHUB, getAppStoreDefinition(slug)] }); await render(); const input = container.querySelector('input[aria-label="MCP server URL"]')!; await act(async () => setInputValue(input, url)); await flushReact(); expect(container.textContent).not.toContain(`This looks like ${name}`); expect(buttonByText(`Use ${name}`)).toBeUndefined(); await act(async () => buttonByText("Continue")!.click()); await flushReact(); await passAccessStep(); expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).not.toContain(`guided setup for ${name}`); expect(buttonByText(`Use ${name}`)).toBeUndefined(); expect(connectAppMock).not.toHaveBeenCalled(); }); it("matches GitHub tool URLs independently of gallery search while chat connectors are hidden", async () => { await render(); await act(async () => setInputValue(container.querySelector('input[placeholder="Search apps…"]')!, "no matching cards")); await act(async () => setInputValue(container.querySelector('input[aria-label="MCP server URL"]')!, "https://github.com/example/repository")); await flushReact(); expect(container.textContent).toContain("This looks like GitHub"); expect(buttonByText("Use GitHub")).toBeTruthy(); await act(async () => buttonByText("Use GitHub")!.click()); await flushReact(); await openAccessAdvanced(); expect(container.textContent).toContain("Connect GitHub as"); expect(container.textContent).toContain("My GitHub account"); expect(container.textContent).not.toContain("Chat with an agent"); }); // ------------------------------------------------------------------------- // Access step (PAP-17835). Identity and agent reach are chosen before any // credential is entered. // ------------------------------------------------------------------------- it.each([false, true])("offers both Anthropic methods without the obsolete REST option (task repair: %s)", async (taskRepair) => { const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({ connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant", }); mockParams.appKey = "anthropic"; listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] }); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, } } }); client.setQueryData(queryKeys.environments.list("company-1"), [ { id: "local-env", name: "Local", driver: "local", status: "active", config: {} }, ]); client.setQueryData(queryKeys.environments.capabilities("company-1"), {}); client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" }); client.setQueryData(queryKeys.instance.generalSettings, {}); client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false }); await render(client, false, taskRepair ? : undefined); await passAccessStep(); expect(container.textContent).toContain("Connection name"); expect(container.textContent).not.toContain("How do you want to connect?"); expect(radioContaining("Use an API key")).toBeUndefined(); expect(container.querySelector('[role="alert"]')).toBeNull(); await act(async () => buttonContaining("Use API key instead")!.click()); await act(async () => buttonContaining("Claude")!.click()); await flushReact(); const key = container.querySelector('input[type="password"]'); expect(key).toBeTruthy(); await act(async () => setInputValue(key!, "fixture-anthropic-ai-key")); await act(async () => buttonByText("Connect")!.click()); await flushReact(); expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key", })); if (!taskRepair) expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions"); expect(connectAppMock).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("Connect for tool access instead"); }); it("states the GitHub identity default and keeps its controls one click away", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); await render(); // PAP-659 C0: the default is printed, not asked. Nothing about identity or // reach blocks the primary action. expect(container.textContent).toContain("Connects as you, available to all agents."); expect(container.textContent).not.toContain("Connect GitHub as"); expect(container.querySelector('[role="radio"]')).toBeNull(); await openAccessAdvanced(); expect(container.textContent).toContain("Connect GitHub as"); expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?"); expect(container.textContent).not.toContain("Choose access before adding credentials"); expect(container.textContent).not.toContain("Set the identity and agent reach first"); expect(container.textContent).not.toContain("Which humans can use this credential?"); expect(container.textContent).not.toContain("Choose where this connection will be available."); // Nothing about the credential itself is on screen yet. expect(container.querySelector('input[type="password"]')).toBeNull(); const radios = Array.from(document.body.querySelectorAll('[role="radio"]')); const myAccount = radios.find((r) => r.textContent?.includes("My GitHub account")); const dedicated = radios.find((r) => r.textContent?.includes("A dedicated account for an agent")); const agentsIPick = radios.find((r) => r.textContent?.includes("Only agents I choose")); const anyAgent = radios.find((r) => r.textContent?.includes("Any agent")); expect(myAccount).toBeTruthy(); expect(dedicated).toBeTruthy(); expect(myAccount?.textContent).toContain("Agents use it only for runs where you are the responsible person."); expect(dedicated?.textContent).toContain("That agent always uses this account, regardless of who starts the run."); expect(agentsIPick?.textContent).toContain("Only selected agents may use your GitHub when you’re responsible."); expect(anyAgent?.textContent).toContain("Every agent may use your GitHub when you’re responsible."); expect(myAccount?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1); expect(dedicated?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1); expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1); expect(anyAgent?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(2); expect(myAccount?.getAttribute("aria-checked")).toBe("true"); expect(dedicated?.getAttribute("aria-checked")).toBe("false"); // Every agent is the default reach for the responsible person's identity. expect(agentsIPick?.getAttribute("aria-checked")).toBe("false"); expect(anyAgent?.getAttribute("aria-checked")).toBe("true"); }); it("keeps provider guidance and card styling out of the access controls", async () => { mockParams.appKey = "pagerduty"; listGalleryMock.mockResolvedValue({ apps: [PAGERDUTY] }); await render(); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.textContent).not.toContain("A PagerDuty API token"); expect(container.textContent).not.toContain("Use a customer-created PagerDuty key"); expect(container.textContent).not.toContain("Review PagerDuty setup requirements"); const accessHeading = Array.from(container.querySelectorAll("h2")).find( (heading) => heading.textContent === "Which humans can use this credential?", ); const accessCard = accessHeading?.closest(".overflow-hidden"); expect(accessCard).toBeTruthy(); expect(accessCard?.classList.contains("bg-card")).toBe(false); expect(accessCard?.classList.contains("shadow-sm")).toBe(false); }); it("defaults to every agent and states an empty explicit selection", async () => { mockParams.appKey = "github"; await render(); await openAccessAdvanced(); // PAP-659 C0: narrowing reach never blocks Connect. An empty selection is // reported in the stated default instead of disabling the primary action. expect(container.textContent).toContain("available to all agents."); await act(async () => { Array.from(document.body.querySelectorAll('[role="radio"]')) .find((r) => r.textContent?.includes("Only agents I choose")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("no agents selected yet."); }); it("reflects a changed access selection in the stated default", async () => { mockParams.appKey = "github"; await render(); await openAccessAdvanced(); await act(async () => { Array.from(document.body.querySelectorAll('[role="radio"]')) .find((r) => r.textContent?.includes("Only agents I choose")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // The sentence above the primary action is the only place this is said, so // it has to track the controls rather than print a fixed default. expect(container.textContent).toContain("no agents selected yet"); const radios = Array.from(document.body.querySelectorAll('[role="radio"]')); expect(radios.find((r) => r.textContent?.includes("Only agents I choose")) ?.getAttribute("aria-checked")).toBe("true"); }); it("returns to the pending skill import when GitHub setup is cancelled", async () => { mockParams.appKey = "github"; rememberSkillSourceReturn("company-1", "new"); await render(); await act(async () => buttonByText("Cancel")?.click()); expect(mockNavigate).toHaveBeenCalledWith("/skills/sources/new"); expect(skillSourceReturnPath("company-1")).toBeNull(); }); it("uses Cancel to exit while the bottom Back button stays in the wizard", async () => { mockParams.appKey = "github"; await render(); await act(async () => { buttonByText("Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mockNavigate).toHaveBeenCalledWith("/apps"); }); /** * Company-wide is a default, not a restriction. Flexible API-key and OAuth * methods must still let the operator deliberately choose a personal identity. */ it("defaults flexible methods to company identity and keeps personal credentials submittable", async () => { mockSearch.value = "source=posthog&method=mcp-api-key"; listGalleryMock.mockResolvedValue({ apps: [{ ...POSTHOG, methods: POSTHOG.methods.filter((method) => method.key === "mcp-api-key") }] }); const identityChoices = () => { const radios = Array.from( document.body.querySelectorAll('[role="radio"]'), ); return { justMe: radios.find((r) => r.textContent?.includes("Just me")), wholeOrg: radios.find((r) => r.textContent?.includes("Any human in the organization")), }; }; // --- API-key-only method: shared by default, personal still offered ------ let root = await render(); await act(async () => { buttonContaining("PostHog")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); const github = identityChoices(); expect(github.wholeOrg?.getAttribute("aria-checked")).toBe("true"); expect(github.justMe?.getAttribute("aria-checked")).toBe("false"); // Present, and genuinely selectable — not the disabled-with-reason state. expect(github.justMe).toBeTruthy(); expect(github.justMe?.disabled).toBe(false); expect(document.body.textContent).not.toContain( "This connection method supports a shared organization credential only.", ); await act(async () => { github.justMe?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { Array.from(document.body.querySelectorAll('[role="radio"]')) .find((r) => r.textContent?.includes("Any agent")) ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "posthog-personal-token")); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // The load-bearing assertion: an API-key-only method reaches the server as // a personal grant. A disabled "Just me" would make this unreachable. expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ galleryKey: "posthog", grantKind: "user", }); // --- Same gallery, identity-bearing method: company by default ----------- await act(async () => root.unmount()); document.body.innerHTML = ""; container = document.createElement("div"); document.body.appendChild(container); mockParams.appKey = "posthog"; mockSearch.value = ""; root = await render(); await openAccessAdvanced(); const posthog = identityChoices(); expect(posthog.justMe?.getAttribute("aria-checked")).toBe("false"); expect(posthog.wholeOrg?.getAttribute("aria-checked")).toBe("true"); expect(posthog.justMe?.disabled).toBe(false); expect(posthog.wholeOrg?.disabled).toBe(false); }); it("shows Gmail's instance configuration notice instead of a dead connect path", async () => { const reason = "Configure Paperclip ID before connecting Gmail."; listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, availability: { available: false, reason } }], }); await render(); const gmail = buttonContaining("Gmail"); expect(gmail?.disabled).toBe(true); expect(gmail?.textContent).toContain(reason); expect(gmail?.textContent).not.toContain("Coming soon"); }); it("defaults Asana to managed sign-in and allows a custom app before enrollment", async () => { mockSearch.value = "source=asana"; listGalleryMock.mockResolvedValue({ apps: [{ ...ASANA, methods: ASANA.methods.filter((method) => !method.oauthStrategy), ownershipAvailability: { ...ASANA.ownershipAvailability, platform_shared: false, } }] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "not_configured", origins: [] }); await render(); await passAccessStep(); expect(container.textContent).toContain("Connect with Paperclip"); await act(async () => { buttonByText("Use your own Asana OAuth app")!.click(); }); await flushReact(); expect(container.textContent).toContain("Your OAuth app"); expect(container.textContent).toContain("API apps do not work with Asana MCP"); expect(container.textContent).not.toContain("You must connect this instance"); await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "asana-client"); }); await flushReact(); expect(buttonByText("Continue to sign in")?.disabled).toBe(true); expect(startCloudConnectorEnrollmentMock).not.toHaveBeenCalled(); }); it.each([true, false])("requires the acting user's saved Asana client secret for reuse (%s)", async (hasSavedOAuthClientSecret) => { mockSearch.value = "source=asana&resume=conn-asana"; listGalleryMock.mockResolvedValue({ apps: [ASANA] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-asana", status: "draft", metadata: { sourceTemplateKey: "asana" } }] }); listConnectionsMock.mockResolvedValue({ connections: [{ id: "conn-asana", applicationId: "app-asana", name: "Asana", authKind: "oauth", credentialPolicy: "per_user", status: "draft", credentialSecretRefs: [], hasSavedOAuthClientSecret, config: { sourceTemplateKey: "asana", connectionMethodKey: "mcp-own-oauth", oauth: { clientId: "saved-asana-client", clientRegistrationSource: "manual" } }, transportConfig: {}, }] }); await render(); await flushReact(); expect(container.querySelector("#curated-oauth-client-id")?.value).toBe("saved-asana-client"); expect(buttonByText("Continue to sign in")?.disabled).toBe(!hasSavedOAuthClientSecret); await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "different-client"); }); await flushReact(); expect(buttonByText("Continue to sign in")?.disabled).toBe(true); }); it("collects customer-owned OAuth client details for a curated manual OAuth app", async () => { listGalleryMock.mockResolvedValue({ apps: [BOX], oauthCallbackUrl: "https://paperclip.example.test/api/tools/oauth/callback", }); mockParams.appKey = "box"; await render(); await passAccessStep(); expect(container.textContent).toContain("needs its own OAuth app"); expect(container.textContent).toContain("Open Box app settings"); // The extra work reads as the provider's limitation, not as Box's normal path. expect(container.textContent).toContain( "does not let Paperclip register itself automatically", ); expect(container.textContent).toContain("Paperclip callback URL"); expect(container.textContent).toContain( "https://paperclip.example.test/api/tools/oauth/callback", ); expect(buttonByText("Continue to sign in")?.disabled).toBe(true); const clientId = container.querySelector("#curated-oauth-client-id")!; const clientSecret = container.querySelector("#curated-oauth-client-secret")!; await act(async () => { setInputValue(clientId, "box-client-id"); setInputValue(clientSecret, "box-client-secret"); }); await flushReact(); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "box", connectionMethodKey: "mcp-own-oauth", oauthClient: { clientId: "box-client-id", clientSecret: "box-client-secret", }, })); }); it("connects Asana through the shared app without client credentials", async () => { listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); mockParams.appKey = "asana"; await render(); await flushReact(); expect(container.textContent).not.toContain("needs its own OAuth app"); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); const primary = buttonByText("Continue to sign in"); expect(primary).toBeTruthy(); expect(primary?.disabled).toBe(false); await act(async () => { primary!.click(); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "asana", connectionMethodKey: "managed", grantKind: "user", })); }); it("allows a custom Asana app from the shared sign-in setup", async () => { listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); mockParams.appKey = "asana"; connectAppMock.mockResolvedValueOnce({ connectionId: "conn-asana", application: { id: "app-asana", name: "Asana" }, connection: { id: "conn-asana" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://app.asana.com/-/oauth_authorize?state=opaque" }, }); await render(); await flushReact(); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); await openAccessAdvanced(); await flushReact(); const customApp = buttonByText("Use your own Asana OAuth app"); expect(customApp).toBeTruthy(); await act(async () => { customApp!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const clientId = container.querySelector("#curated-oauth-client-id"); const clientSecret = container.querySelector("#curated-oauth-client-secret"); expect(clientId).toBeTruthy(); expect(clientSecret).toBeTruthy(); await act(async () => { setInputValue(clientId!, "asana-own-client"); setInputValue(clientSecret!, "asana-own-secret"); }); await flushReact(); await act(async () => { buttonByText("Continue to sign in")!.click(); }); await flushReact(); expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", oauthClient: { clientId: "asana-own-client", clientSecret: "asana-own-secret" }, })); }); it("submits the Postman access mode selected on the setup screen", async () => { listGalleryMock.mockResolvedValue({ apps: [POSTMAN] }); mockParams.appKey = "postman"; connectAppMock.mockResolvedValueOnce({ connectionId: "conn-postman", application: { id: "app-postman", name: "Postman" }, connection: { id: "conn-postman" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://oauth.pstmn.io/authorize?state=opaque" }, }); await render(); await openAccessAdvanced(); const full = radioContaining("Full"); const code = radioContaining("Code"); expect(full).toBeTruthy(); expect(code).toBeTruthy(); expect(full?.getAttribute("aria-checked")).toBe("true"); await act(async () => { code?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(code?.getAttribute("aria-checked")).toBe("true"); await act(async () => { full?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(full?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("US · Browser sign-in")?.getAttribute("aria-checked")).toBe("true"); await act(async () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "postman", connectionMethodKey: "mcp-oauth-full", })); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://oauth.pstmn.io/authorize?state=opaque", ); }); it("opens a manual OAuth app from the same source deep link Browse uses", async () => { listGalleryMock.mockResolvedValue({ apps: [ASANA] }); mockParams.appKey = undefined; mockSearch.value = "source=asana"; await render(); await openAccessAdvanced(); expect(document.body.textContent).toContain("Which humans can use this credential?"); expect(document.body.textContent).toContain("Which agents can use this connection?"); expect(document.body.textContent).not.toContain("Pick the app you want your agents to use."); expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); it("starts a selected app deep link at step one of its two-step setup", async () => { mockSearch.value = "source=gmail"; listGalleryMock.mockResolvedValue({ apps: [GMAIL] }); await render(); // PAP-659: a selected app has one screen, so no stepper at all. expect(document.body.textContent).not.toContain("Step 1 of 2"); expect(document.body.textContent).not.toContain("Pick app ·"); expect(document.body.textContent).toContain("Connect Gmail"); }); it("opens a brokered Gmail deep link at the access step", async () => { mockParams.appKey = "gmail"; mockSearch.value = "byo=1&appKey=gmail&stage=access"; listGalleryMock.mockResolvedValue({ apps: [GMAIL] }); await render(); // PAP-659: a selected app is one screen, so there is no stepper and no // Access stage to deep-link into; a legacy stage=access URL lands here. expect(document.body.textContent).not.toContain("Step 1 of 2"); expect(document.body.textContent).not.toContain("Pick app ·"); expect(document.body.textContent).toContain("Connects for everyone in your organization"); await openAccessAdvanced(); expect(document.body.textContent).toContain("Which humans can use this credential?"); expect(document.body.textContent).toContain("Just me"); expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => { mockParams.appKey = "gmail"; listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)], }); getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment()); await render(); await passAccessStep(); expect(container.textContent).toContain("Connect with Paperclip"); expect(container.textContent).toContain( "You must connect this instance to Paperclip to connect to Gmail (you only need to do this once).", ); expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true); expect(container.textContent).not.toContain("Required once for managed Google sign-in."); expect(container.textContent).not.toContain("needs its own OAuth app"); await act(async () => { buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( "company-1", "Paperclip", "/apps/connect?source=gmail&stage=setup", ); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", ); }); it.each([false, true])("retains task access and interaction through enrollment (popup blocked: %s)", async (popupBlocked) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; const open = vi.spyOn(window, "open").mockReturnValue(popupBlocked ? null : popup as unknown as Window); listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" }); await render(client, false, ); await passAccessStep(); await act(async () => buttonByText("Connect with Paperclip")?.click()); await flushReact(); expect(open).toHaveBeenCalled(); if (popupBlocked) { expect(popup.location.assign).not.toHaveBeenCalled(); } else { expect(popup.location.assign).toHaveBeenCalledWith("https://my-staging.paperclip.app/connections/enroll?id=enroll-test"); } const fallback = container.querySelector('a[target="_blank"]'); expect(fallback?.textContent).toBe("Open authorization in a new tab"); expect(fallback?.href).toBe("https://my-staging.paperclip.app/connections/enroll?id=enroll-test"); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith("company-1", "Paperclip", "/apps/connect?source=gmail&stage=setup&intent=intent-1&enrollment_host=dialog"); listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "active" }); await act(async () => { await client.invalidateQueries({ queryKey: ["cloud-connector", "enrollment"] }); }); await flushReact(); await flushReact(); if (!popupBlocked) expect(popup.close).toHaveBeenCalled(); await openAccessAdvanced(); expect(container.textContent).toContain("What should Paperclip be able to do?"); connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } }); await act(async () => buttonByText("Continue to sign in")?.click()); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ grantKind: "user" })); expect(startOAuthMock).toHaveBeenCalledWith("gmail-1", { asCurrentUser: true, interactionId: "intent-1" }); expect(putConnectionInstallsMock).not.toHaveBeenCalled(); open.mockRestore(); }); it.each(["request", "missing-url", "invalid-url"])("closes the reserved enrollment popup after %s failure", async (failure) => { const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" }); if (failure === "request") { startCloudConnectorEnrollmentMock.mockRejectedValue(new Error("Enrollment unavailable")); } else { startCloudConnectorEnrollmentMock.mockResolvedValue({ status: "pending", verificationUrl: failure === "missing-url" ? undefined : "javascript:alert(1)", }); } await render(undefined, false, ); await passAccessStep(); await act(async () => buttonByText("Connect with Paperclip")?.click()); await flushReact(); expect(popup.close).toHaveBeenCalledOnce(); expect(popup.location.assign).not.toHaveBeenCalled(); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("Finish authorization in the opened window"); }); it("binds OAuth to the task even when setup resumes in the page host", async () => { mockSearch.value = "source=gmail&stage=setup&intent=intent-1"; listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] }); connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "shared" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } }); await render(); await act(async () => buttonByText("Continue to sign in")?.click()); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith("gmail-1", { asCurrentUser: false, interactionId: "intent-1" }); expect(navigateTopLevelMock).not.toHaveBeenCalledWith("https://example.test/unbound"); }); it.each(["2020-01-01T00:00:00.000Z", "2099-01-01T00:00:00.000Z"])( "revalidates a cached pending enrollment before continuing (expiry %s)", async (expiresAt) => { mockSearch.value = "source=github&stage=setup"; listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GITHUB)], }); getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "pending", brokerBaseUrl: "https://my-staging.paperclip.app", instanceId: "inst-test", environment: "staging", origins: [], verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=cached", expiresAt, }); await render(); await act(async () => { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( "company-1", "Paperclip", "/apps/connect?source=github&stage=setup", ); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", ); expect(navigateTopLevelMock).not.toHaveBeenCalledWith( "https://my-staging.paperclip.app/connections/enroll?id=cached", ); }, ); it("labels GitHub's setup transition as the provider handoff it now is", async () => { mockSearch.value = "source=github"; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); await render(); // PAP-659: the screen that used to precede the handoff is gone, so the one // primary action names the provider instead of a generic "Continue". expect(buttonByText("Continue")).toBeUndefined(); const handoff = buttonByText("Continue to GitHub"); expect(handoff).toBeDefined(); expect(handoff?.disabled).toBe(false); await openAccessAdvanced(); expect(container.textContent).toContain("How do you want to connect?"); expect(startOAuthMock).not.toHaveBeenCalled(); expect(connectAppMock).not.toHaveBeenCalled(); }); it("keeps provider-specific wording and the handoff cue for direct OAuth", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValue({ apps: [NOTION] }); await render(); const continueButton = buttonByText("Continue to Notion"); expect(continueButton).toBeDefined(); expect(continueButton?.querySelector(".lucide-arrow-up-right")).not.toBeNull(); }); it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GITHUB)], }); getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment()); await render(); expect(container.textContent).toContain("Connects as you"); await openAccessAdvanced(); expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true"); expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?"); // Enrolment is the screen; its own action is the only primary one. expect(buttonByText("Connect with Paperclip")).toBeDefined(); expect(buttonByText("Continue to GitHub")).toBeUndefined(); await passAccessStep(); expect(container.textContent).toContain("Connect with Paperclip"); expect(container.textContent).not.toContain("GitHub token"); }); it("restores the setup step after the one-time enrollment callback", async () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB_MANAGED] }); await render(); expect(container.textContent).toContain("Continue to GitHub"); expect(container.textContent).not.toContain("Connect GitHub as"); expect(container.textContent).not.toContain("Connect with Paperclip"); }); it("explains unavailable GitHub sign-in without silently switching to a PAT", async () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; listGalleryMock.mockResolvedValueOnce({ apps: [customerOwnedApp(GITHUB)], }); await render(); expect(container.textContent).not.toContain("Chat with an agent"); expect(container.textContent).not.toContain("GitHub App ID"); expect(container.textContent).toContain("GitHub sign-in is unavailable"); expect(container.textContent).not.toContain("Your GitHub key"); expect(container.querySelector('input[type="password"]')).toBeNull(); expect(buttonByText("Try again")?.disabled).toBe(false); listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); await act(async () => buttonByText("Try again")!.click()); await flushReact(); expect(container.textContent).toContain("Continue to GitHub"); expect(container.textContent).not.toContain("GitHub sign-in is unavailable"); }); it("keeps GitHub sign-in intent when enrollment recovery reveals an unavailable profile", async () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; listGalleryMock.mockResolvedValueOnce({ apps: [customerOwnedApp(GITHUB)], }); getCloudConnectorEnrollmentMock .mockRejectedValueOnce(new Error("Cloud unavailable")) .mockResolvedValueOnce(activeConnectorEnrollment()); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await render(queryClient); expect(container.textContent).toContain("Paperclip couldn’t check Cloud registration. Try again."); expect(container.textContent).not.toContain("Your GitHub key"); await act(async () => { await queryClient.invalidateQueries({ queryKey: ["cloud-connector", "enrollment"] }); }); await flushReact(); expect(container.textContent).toContain("GitHub sign-in is unavailable"); expect(container.textContent).not.toContain("Your GitHub key"); expect(buttonByText("Try again")?.disabled).toBe(false); }); it("preserves a dedicated agent identity across the full-page enrollment callback", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GITHUB)], capabilities: { canCreateOrganizationGrant: true, organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }, }); getCloudConnectorEnrollmentMock.mockResolvedValue(unconfiguredConnectorEnrollment()); await render(); await openAccessAdvanced(); await act(async () => { radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { document.body.querySelector('[aria-label="Allow Ada"]') ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await act(async () => { buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(JSON.parse(window.sessionStorage.getItem( "paperclip.connector-enrollment-access:github", ) ?? "null")).toEqual({ companyId: "company-1", grantKind: "agent", installChoice: "specific", agentIds: ["agent-1"], }); await act(async () => mountedRoot?.unmount()); mountedRoot = null; container.innerHTML = ""; mockParams.appKey = undefined; mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; mockCompany.value = { selectedCompanyId: undefined, selectedCompany: null }; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); getCloudConnectorEnrollmentMock.mockResolvedValue(activeConnectorEnrollment()); const coldLoadClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await render(coldLoadClient); expect(window.sessionStorage.getItem("paperclip.connector-enrollment-access:github")).toBeNull(); mockCompany.value = { selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" }, }; await act(async () => { mountedRoot?.render( , ); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("Connects as a dedicated agent account"); expect(window.sessionStorage.getItem( "paperclip.connector-enrollment-access:github", )).toBeNull(); await act(async () => { buttonByText("Continue to GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "github", grantKind: "agent", subjectAgentId: "agent-1", })); }); it.each([...new Set(Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug))] .flatMap((slug) => [false, true].map((enrollmentReturn) => ({ slug, enrollmentReturn }))))( "preserves personal Workspace access for $slug when changing method (enrollment return: $enrollmentReturn)", async ({ slug, enrollmentReturn }) => { const definition = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug)!; const readMethod = definition.methods.find((method) => method.key === "paperclip-read")!; mockSearch.value = enrollmentReturn ? `source=${slug}&stage=setup&cloud_connector=enrolled` : `source=${slug}`; if (enrollmentReturn) { window.sessionStorage.setItem(`paperclip.connector-enrollment-access:${slug}`, JSON.stringify({ companyId: "company-1", grantKind: "user", installChoice: "all", agentIds: [], })); } listGalleryMock.mockResolvedValue({ apps: [{ ...definition, ownershipAvailability: { ...definition.ownershipAvailability, platform_shared: true }, }] }); await render(); await openAccessAdvanced(); if (!enrollmentReturn) { await act(async () => { radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) { const readChoice = radioContaining(readMethod.capabilityProfile!.label); expect(readChoice).not.toBeNull(); await act(async () => { readChoice!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); } await flushReact(); // Change auth methods too, including apps with only one capability. expect(container.textContent).not.toContain("Connect with Paperclip"); expect(container.textContent).not.toContain("needs its own OAuth app"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); const customerAuth = buttonByText("Use your own Google OAuth app"); expect(customerAuth).toBeDefined(); expect(customerAuth?.getAttribute("aria-expanded")).toBe("false"); await act(async () => { customerAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("needs its own OAuth app"); expect(container.textContent).toContain("Client ID"); expect(buttonByText("Continue to sign in")?.disabled).toBe(true); const managedAuth = buttonByText("Use Paperclip instead"); expect(managedAuth).toBeDefined(); expect(managedAuth?.getAttribute("aria-expanded")).toBe("true"); const fieldsRegion = document.getElementById(managedAuth!.getAttribute("aria-controls")!); expect(fieldsRegion?.getAttribute("role")).toBe("region"); expect(fieldsRegion?.textContent).toContain("Client ID"); await act(async () => { managedAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).not.toContain("needs its own OAuth app"); await act(async () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user", })); }); it("never renders self-host enrollment when the connector identity is already active", async () => { mockSearch.value = "source=gmail&stage=setup"; listGalleryMock.mockResolvedValueOnce({ apps: [customerOwnedApp(GMAIL)], }); await render(); expect(container.textContent).not.toContain("You must connect this instance to Paperclip"); expect(container.textContent).not.toContain("Connect with Paperclip"); }); it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => { mockParams.appKey = "google-drive"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] }); await render(); expect(container.textContent).not.toContain("Google Developer Preview access required"); expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers"); expect(container.textContent).not.toContain("final project-registration email"); expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment"); await openAccessAdvanced(); expect(radioContaining("Just me")).toBeTruthy(); expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true"); await passAccessStep(); expect(container.textContent).toContain("Read & create"); expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false"); expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account"); expect(container.textContent).toContain("Review requirements"); expect(container.textContent).toContain("needs its own OAuth app"); }); it("renders Google Calendar as one minimal, unboxed setup screen", async () => { mockParams.appKey = "google-calendar"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] }); await render(); await passAccessStep(); const duplicateHeadings = Array.from(container.querySelectorAll("h1, h2")).filter( (heading) => heading.textContent?.trim() === "Connect Google Calendar", ); expect(duplicateHeadings).toHaveLength(1); await openAccessAdvanced(); expect(container.textContent).toContain("What should Paperclip be able to do?"); expect(container.textContent).toContain("Review requirements"); expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events."); expect(container.textContent).not.toContain("All event mutations require approval."); expect(container.textContent).not.toContain("Google Workspace MCP servers are in Developer Preview."); expect(container.textContent).not.toContain("We filled this in from the app."); expect(container.textContent).not.toContain("You’ll continue to Google Calendar"); expect(container.textContent).not.toContain("You’ll sign in before anything turns on."); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const capabilityQuestion = Array.from(container.querySelectorAll("label")).find( (label) => label.textContent === "What should Paperclip be able to do?", ); expect(capabilityQuestion?.closest(".max-w-xl")?.classList.contains("bg-card")).toBe(false); }); it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => { mockParams.appKey = "shopify"; listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] }); await render(); expect(container.textContent).not.toContain("Launch the storefront before connecting"); expect(container.textContent).not.toContain("Storefront visibility to Public"); expect(container.textContent).not.toContain("Open Shopify Admin"); await passAccessStep(); expect(container.textContent).not.toContain("Launch the storefront before connecting"); expect(container.textContent).not.toContain("Open Shopify Admin"); }); /** * Design §"Question 2": a member who may create a personal grant but cannot * configure a company-wide install sees **Any agent** disabled with the * reason — not hidden. Disabling it is only half the job; Continue has to * refuse it too, or the forbidden choice is still submittable by keyboard. * * Driven through AppsConnect so this proves the pre-connection capability * returned with the gallery reaches the real create flow. */ it("disables Any agent and blocks Continue when the member cannot install company-wide", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG], capabilities: { canCreateOrganizationGrant: false, organizationGrantReason: "Only connection managers can share this credential.", canSetCompanyInstall: false, companyInstallReason: "Your company policy limits this choice to connection managers.", }, }); await render(); await openAccessAdvanced(); const anyAgent = Array.from( document.body.querySelectorAll('[role="radio"]'), ).find((r) => r.textContent?.includes("Any agent")); // Visible and not selectable; the reason stays available without adding // visual subtext to the compact option. expect(anyAgent).toBeTruthy(); expect(anyAgent?.disabled).toBe(true); expect(anyAgent?.textContent).not.toContain( "Your company policy limits this choice to connection managers.", ); expect(anyAgent?.getAttribute("title")).toBe( "Your company policy limits this choice to connection managers.", ); expect(anyAgent?.getAttribute("aria-label")).toContain( "Your company policy limits this choice to connection managers.", ); const organization = radioContaining("Any human in the organization"); expect(organization?.disabled).toBe(true); expect(organization?.getAttribute("title")).toBe( "Only connection managers can share this credential.", ); expect(radioContaining("Just me")?.disabled).toBe(false); // "Just agents I pick" is the live alternative, so the step is not a dead end. const pick = Array.from( document.body.querySelectorAll('[role="radio"]'), ).find((r) => r.textContent?.includes("Just agents I pick")); expect(pick?.disabled).toBe(false); // PAP-659: a reach the member cannot grant is reported above the primary // action rather than disabling it, and "Just agents I pick" is the live // alternative, so the screen is not a dead end. expect(container.textContent).toContain( "Your company policy limits this choice to connection managers.", ); }); it("opens the selected app directly on its setup route", async () => { mockParams.appKey = "github"; await render(); // PAP-659: a deep-linked app lands on its one connect screen. Identity and // reach are stated there and changed in the same Advanced disclosure. expect(container.textContent).toContain("Connects for everyone in your organization"); await openAccessAdvanced(); expect(container.textContent).toContain("Connect GitHub as"); expect(container.textContent).toContain("Connect GitHub"); expect(container.textContent).not.toContain("Pick the app you want your agents to use."); }); it("connects Superagent with only an organization API key", async () => { mockParams.appKey = "superagent"; listGalleryMock.mockResolvedValueOnce({ apps: [SUPERAGENT] }); await render(); expect(radioContaining("Sign in with")).toBeFalsy(); // The Ask-first advice must be visible on the key form, which renders the // credential helper text rather than method warnings. expect(container.textContent).toContain("set billable and destructive actions to Ask first"); const keyInput = container.querySelector('input[type="password"]'); expect(keyInput).toBeTruthy(); expect(buttonByText("Connect")?.disabled).toBe(true); await act(async () => { setInputValue(keyInput!, "sk_live_test-key"); }); await flushReact(); const submit = buttonByText("Connect"); expect(submit?.disabled).toBe(false); await act(async () => { submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "superagent", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "sk_live_test-key" }, })); }); it("defaults Gauge to browser sign-in and connects an organization API key once entered", async () => { mockParams.appKey = "gauge"; listGalleryMock.mockResolvedValueOnce({ apps: [GAUGE] }); await render(); // The publish warning must be visible on the default sign-in path, before // the operator opens Advanced or switches to the API-key method. expect(container.textContent).toContain("can publish to your connected CMS, including live"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await openAccessAdvanced(); expect(radioContaining("Sign in with Gauge")?.getAttribute("aria-checked")).toBe("true"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("set publish actions to Ask first"); const keyInput = container.querySelector('input[type="password"]'); expect(keyInput).toBeTruthy(); expect(buttonByText("Connect")?.disabled).toBe(true); await act(async () => { setInputValue(keyInput!, "gauge-test-key"); }); await flushReact(); const submit = buttonByText("Connect"); expect(submit?.disabled).toBe(false); await act(async () => { submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "gauge", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "gauge-test-key" }, })); }); it("enables Neon's Connect button only once the API key is entered, with pin and read-only optional", async () => { mockParams.appKey = "neon"; listGalleryMock.mockResolvedValueOnce({ apps: [NEON] }); await render(); await openAccessAdvanced(); expect(radioContaining("Sign in with Neon")?.getAttribute("aria-checked")).toBe("true"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const keyInput = container.querySelector('input[type="password"]'); expect(keyInput).toBeTruthy(); expect(container.textContent).toContain("Pin to project ID"); expect(container.querySelector('input[placeholder="Optional Neon project ID"]')).toBeTruthy(); expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); // The key is the only required input on this method: Connect waits for it // and for nothing else, since both narrowing controls are optional. expect(buttonByText("Connect")?.disabled).toBe(true); await act(async () => { setInputValue(keyInput!, "napi_test-key"); }); await flushReact(); const submit = buttonByText("Connect"); expect(submit?.disabled).toBe(false); await act(async () => { submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "neon", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "napi_test-key" }, configValues: { readOnly: false }, })); }); it("connects PostHog without a project ID and keeps optional controls advanced", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] }); await render(); // PAP-659: identity is stated, not asked, and the method chooser moved // into the same Advanced disclosure. expect(container.textContent).toContain("Connects for everyone in your organization"); await openAccessAdvanced(); expect(container.textContent).toContain("How do you want to connect?"); // PAP-659 C1: OAuth is strictly less work than a key, so it is preselected. expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).not.toContain("Where are credentials managed?"); expect(container.textContent).not.toContain("Vercel Connect"); const keyInput = container.querySelector('input[type="password"]'); const advanced = buttonByText("Change"); expect(keyInput).toBeTruthy(); // PAP-659: the optional controls share the one Advanced disclosure with // the access defaults, so they are visible because it is already open — // and they are still optional, so none of them blocks Connect. expect(advanced).toBeTruthy(); expect(container.textContent).toContain("Pin to project ID"); expect(container.querySelector('input[placeholder="Optional numeric project ID"]')).toBeTruthy(); expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); expect(container.textContent).toContain("Feature groups"); expect(container.textContent).toContain("Individual tools"); expect(container.textContent).not.toContain("Tool response mode"); await act(async () => { setInputValue(keyInput!, "phx_test-key"); }); await flushReact(); const submit = buttonByText("Connect"); expect(submit).toBeTruthy(); expect(submit?.disabled).toBe(false); await act(async () => { submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", name: "PostHog for the organization", credentialSource: "paperclip_vault", credentialValues: { "credentials.authorization": "phx_test-key" }, configValues: { readOnly: false, mode: "tools", }, applicationId: undefined, // Flexible methods default to a company identity, represented by omitting // the optional personal grantKind from the request. }); }); it("hands reviewed PostHog credentials to Vercel and submits only the connector reference", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG], credentialSources: { vercelConnect: { available: true, enabled: true, authentication: "access_token", manageUrl: "https://vercel.com/connect", reason: null, }, }, }); await render(undefined, false, ); await openAccessAdvanced(); await act(async () => { radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("Create or attach the connector in Vercel"); expect(container.querySelector('a[href="https://vercel.com/connect"]')).toBeTruthy(); expect(container.querySelector('input[type="password"]')).toBeNull(); const connectorInput = container.querySelector('#vercel-connect-connector'); expect(connectorInput).toBeTruthy(); await act(async () => { setInputValue(connectorInput!, "posthog/paperclip"); }); await flushReact(); const vercelSubmit = buttonByText("Validate and connect"); expect(vercelSubmit).toBeTruthy(); expect(vercelSubmit?.disabled).toBe(false); await act(async () => { vercelSubmit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", name: "PostHog for the organization", credentialSource: "vercel_connect", vercelConnect: { connector: "posthog/paperclip" }, configValues: { readOnly: false, mode: "tools" }, applicationId: undefined, }); expect(JSON.stringify(connectAppMock.mock.calls[0])).not.toContain("credentialValues"); }); it("folds optional customer-owned OAuth details under Change", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] }); await render(); await passAccessStep(); await act(async () => { radioContaining("Sign in with PostHog")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const advanced = buttonByText("Change"); expect(advanced?.getAttribute("aria-expanded")).toBe("false"); expect(container.textContent).not.toContain("Use your own OAuth app"); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { advanced?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("Use your own OAuth app"); expect(container.querySelector("#curated-oauth-client-id")).toBeTruthy(); expect(container.textContent).toContain("Paperclip callback URL"); }); it("shows unavailable Vercel configuration only inside the isolated Vercel entry point", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG], credentialSources: { vercelConnect: { available: false, enabled: true, authentication: null, manageUrl: "https://vercel.com/connect", reason: "Vercel Connect needs workload OIDC or an instance access token.", }, }, }); await render(undefined, false, ); expect(container.textContent).toContain("Connect through Vercel"); expect(container.textContent).toContain("Vercel Connect needs workload OIDC or an instance access token."); expect(buttonContaining("PostHog")?.disabled).toBe(true); expect(container.textContent).not.toContain("Where are credentials managed?"); }); it("renders the same PostHog method choices in page and task-dialog hosts", async () => { const checkMethodChoices = async () => { await openAccessAdvanced(); expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false"); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); await act(async () => { radioContaining("Use a personal API key")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("false"); expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("true"); expect(buttonByText("Connect")?.disabled).toBe(true); const keyInput = container.querySelector('input[type="password"]'); expect(keyInput).toBeTruthy(); await act(async () => setInputValue(keyInput!, "phx_host-test-key")); await flushReact(); expect(buttonByText("Connect")?.disabled).toBe(false); await act(async () => { radioContaining("Sign in with PostHog")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(buttonByText("Continue to sign in")?.disabled).toBe(false); }; mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValue({ apps: [POSTHOG] }); const pageRoot = await render(); await checkMethodChoices(); await act(async () => pageRoot.unmount()); container.innerHTML = ""; mockParams.appKey = undefined; const dialogRoot = await render(undefined, false, ( )); expect(container.textContent).toContain("available to the agent that asked for it"); const continueButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.trim().startsWith("Continue"), ); expect(continueButton).toBeTruthy(); expect(continueButton?.disabled).toBe(false); await checkMethodChoices(); await act(async () => { buttonByText("Continue to sign in")!.click(); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); await act(async () => dialogRoot.unmount()); }); it("reserves the task-dialog OAuth popup before awaiting connection setup and assigns only the validated URL", async () => { listGalleryMock.mockResolvedValue({ apps: [NOTION] }); let resolveConnect!: (value: unknown) => void; connectAppMock.mockReturnValue(new Promise((resolve) => { resolveConnect = resolve; })); const assign = vi.fn(); const focus = vi.fn(); const openSpy = vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign }, focus, } as unknown as Window); const dialogRoot = await render(undefined, false, ( )); await passAccessStep(); await submitCuratedOAuthSetup(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith( "about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes", ); expect(openSpy.mock.invocationCallOrder[0]).toBeLessThan(connectAppMock.mock.invocationCallOrder[0]!); expect(assign).not.toHaveBeenCalled(); resolveConnect({ connectionId: "conn-notion", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-notion", credentialPolicy: "per_user" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://untrusted-return-value.test/ignored" }, }); await flushReact(); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith("conn-notion", { asCurrentUser: true, interactionId: "interaction-1", }); expect(assign).toHaveBeenCalledWith("https://mcp.notion.com/authorize?state=resumed"); expect(assign).not.toHaveBeenCalledWith("https://untrusted-return-value.test/ignored"); expect(focus).toHaveBeenCalled(); openSpy.mockRestore(); await act(async () => dialogRoot.unmount()); }); it("returns a standalone GitHub popup to its host only after verifying the saved connection", async () => { listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); connectAppMock.mockResolvedValue({ connectionId: "conn-github", application: { id: "app-github", name: "GitHub" }, connection: { id: "conn-github", credentialPolicy: "per_user" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth" }, }); const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); const onComplete = vi.fn(); getConnectionMock.mockResolvedValue({ id: "conn-github", status: "active" }); const dialogRoot = await render(undefined, false, ); await passAccessStep(); await act(async () => buttonByText("Continue to GitHub")!.click()); await flushReact(); await flushReact(); expect(connectAppMock, container.textContent ?? "").toHaveBeenCalled(); expect(startOAuthMock, container.textContent ?? "").toHaveBeenCalledWith("conn-github", { asCurrentUser: true }); expect(onComplete).not.toHaveBeenCalled(); popup.location.href = `${window.location.origin}/CO/apps/conn-github/permissions?success=1`; await act(async () => { await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "conn-github" }), { timeout: 2500 }); }); expect(getConnectionMock).toHaveBeenCalledWith("conn-github"); expect(popup.close).toHaveBeenCalled(); await act(async () => dialogRoot.unmount()); }); it("keeps the task dialog recoverable when the browser blocks its reserved OAuth popup", async () => { listGalleryMock.mockResolvedValue({ apps: [NOTION] }); connectAppMock.mockResolvedValue({ connectionId: "conn-notion", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-notion", credentialPolicy: "per_user" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); const openSpy = vi.spyOn(window, "open").mockReturnValue(null); const onPhaseChange = vi.fn(); const dialogRoot = await render(undefined, false, ( )); await passAccessStep(); await submitCuratedOAuthSetup(); await flushReact(); await flushReact(); expect(container.textContent).toContain("Open sign-in in a new tab to continue"); expect(container.textContent).toContain("Try again"); expect(onPhaseChange).toHaveBeenCalledWith("needs_retry"); const fallback = container.querySelector('a[target="_blank"]'); expect(fallback?.textContent).toBe("Open sign-in in a new tab"); expect(fallback?.href).toContain("https://mcp.notion.com/authorize"); expect(fallback?.rel).toBe("noopener noreferrer"); openSpy.mockRestore(); await act(async () => dialogRoot.unmount()); }); it("chooses identity before the Notion source deep link opens provider sign-in", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-notion", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-notion" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://mcp.notion.com/authorize?state=opaque" }, }); await render(); expect(connectAppMock).not.toHaveBeenCalled(); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("Connects for everyone in your organization"); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).not.toContain("Choose access before sign-in"); const identityRadios = Array.from(document.body.querySelectorAll('[role="radio"]')); expect(identityRadios.find((radio) => radio.textContent?.includes("Any human in the organization"))?.getAttribute("aria-checked")) .toBe("true"); await passAccessStep(); await submitCuratedOAuthSetup(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock).toHaveBeenCalledWith("company-1", { galleryKey: "notion", connectionMethodKey: "mcp-oauth", name: "Notion for the organization", credentialSource: "paperclip_vault", credentialValues: {}, configValues: undefined, applicationId: undefined, }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=opaque", ); }); it("keeps the same steps when Notion sign-in takes over the wizard", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); connectAppMock.mockReturnValueOnce(new Promise(() => {})); await render(); // PAP-659: a curated connector is a single screen, so it shows no stepper // at all. Connecting must not conjure one. expect(stepLabelsOnScreen()).toEqual([]); expect(stepDotCount()).toBe(0); await passAccessStep(); await submitCuratedOAuthSetup(); expect(container.textContent).toContain("Preparing secure sign-in"); expect(container.textContent).toContain("Connect Notion"); expect(container.textContent).toContain("Preparing…"); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(stepLabelsOnScreen()).toEqual([]); expect(stepDotCount()).toBe(0); }); it("backs from the sign-in checkpoint out to the connector gallery", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); connectAppMock.mockReturnValueOnce(new Promise(() => {})); await render(); await passAccessStep(); await submitCuratedOAuthSetup(); await act(async () => { buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockNavigate).toHaveBeenCalledWith("/apps"); }); it("resumes an existing Notion OAuth connection instead of creating another draft", async () => { const interactionId = "11111111-1111-4111-8111-111111111111"; mockSearch.value = `source=notion&intent=${interactionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("draft")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [notionConnection("conn-existing", "per_user", "draft")], }); startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing")); await render(); expect(container.textContent).not.toContain("Reconnect keeps this identity type"); expect(container.textContent).toContain("agent access stays as it is"); expect(startOAuthMock).not.toHaveBeenCalled(); await passAccessStep(); await submitCuratedOAuthSetup(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", { asCurrentUser: true, interactionId, }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=existing", ); }); it("resumes the exact draft selected by Finish setup", async () => { mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [ notionConnection("11111111-1111-4111-8111-111111111111", "shared", "draft"), notionConnection("22222222-2222-4222-8222-222222222222", "per_user", "draft"), ], }); await render(); await vi.waitFor(() => { expect(container.textContent).toContain("Finish connecting Notion"); }); expect(getConnectionMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("identity and agent access will stay the same"); expect(container.textContent).not.toContain("Choose access before sign-in"); expect(buttonByText("Continue to sign in")).toBeUndefined(); await act(async () => { buttonByText("Finish with Notion")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", { asCurrentUser: true }, ); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=resumed", ); }); it("resumes a hidden provider draft only after matching its company-visible application", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-clickup", status: "active", metadata: { sourceTemplateKey: "clickup" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, applicationId: "app-clickup", authKind: "oauth", credentialPolicy: "per_user", status: "draft", config: { sourceTemplateKey: "clickup" }, transportConfig: {}, }], }); startOAuthMock.mockResolvedValueOnce(oauthStartResponse(connectionId, "clickup", "https://app.clickup.com/api?state=resumed")); await render(); await vi.waitFor(() => { expect(container.textContent).toContain("Finish connecting ClickUp"); }); expect(getAppStoreDefinition("clickup")).toBeNull(); await act(async () => { buttonByText("Finish with ClickUp")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true }); }); it("keeps a resumed unauthenticated draft through its finish-time active transition", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; const draft = { id: connectionId, companyId: "company-1", applicationId: "app-context7", authKind: "none", credentialPolicy: "shared", status: "draft", transport: "mcp_remote", config: { sourceTemplateKey: "context7", connectionMethodKey: "mcp", url: "https://mcp.context7.com/mcp" }, transportConfig: { sourceTemplateKey: "context7" }, }; mockSearch.value = `source=context7&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-context7", companyId: "company-1", status: "active", metadata: { sourceTemplateKey: "context7" }, }], }); listConnectionsMock .mockResolvedValueOnce({ connections: [draft] }) .mockResolvedValue({ connections: [{ ...draft, status: "active" }] }); connectAppMock.mockResolvedValueOnce({ connectionId, connection: draft, application: { name: "Context7" }, catalog: [{ id: "tool-context7", name: "resolve-library-id", status: "active" }], actions: { readOnly: [{ catalogEntryId: "tool-context7", riskLevel: "read" }], canMakeChanges: [] }, }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Connect Context7")); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await vi.waitFor(() => expect(finishAppMock).toHaveBeenCalledOnce()); await vi.waitFor(() => { expect(container.textContent).not.toContain("This setup can’t be resumed"); expect(container.textContent).toContain("Context7 is ready."); }); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connectionId })); expect(finishAppMock).toHaveBeenCalledWith("company-1", connectionId, expect.any(Object)); expect(startOAuthMock).not.toHaveBeenCalled(); await act(async () => { buttonByText("View connection")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mockNavigate).toHaveBeenLastCalledWith(`/apps/${connectionId}/permissions`); }); it("restores a valid completed resume URL to the exact connection permissions page", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-clickup", companyId: "company-1", status: "active", metadata: { sourceTemplateKey: "clickup" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, companyId: "company-1", applicationId: "app-clickup", authKind: "oauth", credentialPolicy: "per_user", status: "active", config: { sourceTemplateKey: "clickup" }, transportConfig: { sourceTemplateKey: "clickup" }, }], }); await render(); await vi.waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); }); expect(connectAppMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("does not treat a draft connection as completed from the URL stage", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-clickup", companyId: "company-1", status: "active", metadata: { sourceTemplateKey: "clickup" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, companyId: "company-1", applicationId: "app-clickup", authKind: "oauth", credentialPolicy: "per_user", status: "draft", config: { sourceTemplateKey: "clickup" }, transportConfig: { sourceTemplateKey: "clickup" }, }], }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Finish connecting ClickUp")); expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); expect(container.textContent).not.toContain("ClickUp is ready."); expect(connectAppMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); }); it("does not treat a mismatched active connection as completed from the URL stage", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-monday", companyId: "company-1", status: "active", metadata: { sourceTemplateKey: "monday" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, companyId: "company-1", applicationId: "app-monday", authKind: "oauth", credentialPolicy: "per_user", status: "active", config: { sourceTemplateKey: "clickup" }, transportConfig: { sourceTemplateKey: "clickup" }, }], }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed")); expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true }); expect(connectAppMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("rejects a matching-provider resume when the saved connection was already active", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-clickup", companyId: "company-1", status: "active", metadata: { sourceTemplateKey: "clickup" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, companyId: "company-1", applicationId: "app-clickup", authKind: "oauth", credentialPolicy: "per_user", status: "active", config: { sourceTemplateKey: "clickup" }, transportConfig: { sourceTemplateKey: "clickup" }, }], }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed")); expect(connectAppMock).not.toHaveBeenCalled(); expect(finishAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("does not resolve a hidden provider when a resumed draft belongs to another provider", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-monday", status: "active", metadata: { sourceTemplateKey: "monday" }, }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, applicationId: "app-monday", authKind: "oauth", credentialPolicy: "per_user", status: "draft", config: { sourceTemplateKey: "clickup" }, transportConfig: {}, }], }); await render(); await vi.waitFor(() => { expect(container.textContent).toContain("This setup can’t be resumed"); }); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("keeps an unavailable hidden draft on the blocked recovery screen", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=clickup&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [] }); listConnectionsMock.mockResolvedValueOnce({ connections: [] }); await render(); await vi.waitFor(() => { expect(container.textContent).toContain("The saved connection no longer exists"); }); expect(mockNavigate).not.toHaveBeenCalled(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("shows installation recovery for GitHub even when an advanced PAT method is available", async () => { const connectionId = "22222222-2222-4222-8222-222222222222"; mockSearch.value = `source=github&resume=${connectionId}&oauth=failed&code=github_installation_required&installation_url=https%3A%2F%2Fgithub.com%2Fapps%2Fpaperclip-for-github%2Finstallations%2Fnew`; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-github", status: "draft", metadata: { sourceTemplateKey: "github" } }] }); listConnectionsMock.mockResolvedValue({ connections: [{ id: connectionId, applicationId: "app-github", authKind: "oauth", credentialPolicy: "per_user", status: "draft", config: { sourceTemplateKey: "github", connectionMethodKey: "managed" }, transportConfig: {}, }] }); await render(); await flushReact(); expect(container.textContent).toContain("Install Paperclip and grant at least one repository"); expect(container.querySelector('a[href="https://github.com/apps/paperclip-for-github/installations/new"]')?.textContent).toBe("Install Paperclip on GitHub"); expect(container.textContent).not.toContain("Your GitHub key"); await act(async () => buttonByText("Try again")!.click()); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true }); expect(connectAppMock).not.toHaveBeenCalled(); }); it("returns a declined OAuth draft to the same one-action resume checkpoint", async () => { mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222&oauth=denied&code=oauth_authorization_denied"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("draft")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: "22222222-2222-4222-8222-222222222222", applicationId: "app-notion", authKind: "oauth", credentialPolicy: "shared", status: "draft", config: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" }, transportConfig: {}, }], }); await render(); await flushReact(); expect(container.textContent).toContain("Notion couldn’t connect"); expect(container.textContent).toContain("Your saved connection was not changed"); expect(buttonByText("Try again")).toBeTruthy(); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", { asCurrentUser: false }, ); }); it.each([ ["personal", "per_user", true], ["organization", "shared", false], ] as const)("preserves an active %s identity when reconnecting Notion", async ( _label, credentialPolicy, asCurrentUser, ) => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [notionConnection("conn-existing", credentialPolicy, "active")], }); startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing")); await render(); expect(container.textContent).toContain( credentialPolicy === "per_user" ? "Connects as you" : "Connects for everyone in your organization", ); expect(container.textContent).toContain("agent access stays as it is"); await passAccessStep(); await submitCuratedOAuthSetup(); expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", { asCurrentUser, }); }); it("revives an archived Notion connection without reopening its organization identity choice", async () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("archived")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [notionConnection("conn-archived", "shared", "archived")], }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-archived", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-archived", credentialPolicy: "shared", status: "draft", }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-archived", "notion", "https://mcp.notion.com/authorize?state=revived")); await render(); expect(container.textContent).toContain("Connects for everyone in your organization"); await passAccessStep(); await submitCuratedOAuthSetup(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "notion", applicationId: "app-notion", })); expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind"); expect(startOAuthMock).toHaveBeenCalledWith("conn-archived", { asCurrentUser: false, }); }); it("offers recovery when provider details fail during an exact retained reconnect", async () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockRejectedValueOnce(new Error("Gallery unavailable")); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("archived")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [notionConnection("conn-archived", "shared", "archived")], }); await render(); expect(container.textContent).toContain("Couldn’t load connection setup"); expect(container.textContent).toContain("The retained connection was not changed"); expect(container.querySelector('[aria-label="Loading retained connection setup"]')).toBeNull(); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(listGalleryMock).toHaveBeenCalledTimes(2); expect(container.textContent).not.toContain("Couldn’t load connection setup"); }); it.each(["connection", "application"] as const)( "keeps an exact retained reconnect retryable when the %s lookup fails", async (failedLookup) => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); const applications = { applications: [notionApplication("archived")], }; const connections = { connections: [notionConnection("conn-archived", "shared", "archived")], }; if (failedLookup === "connection") { listConnectionsMock .mockRejectedValueOnce(new Error("Connection lookup unavailable")) .mockResolvedValueOnce(connections); listApplicationsMock.mockResolvedValue(applications); } else { listApplicationsMock .mockRejectedValueOnce(new Error("Application lookup unavailable")) .mockResolvedValueOnce(applications); listConnectionsMock.mockResolvedValue(connections); } await render(); expect(container.textContent).toContain("Couldn’t load connection setup"); expect(container.textContent).toContain("The retained connection was not changed"); expect(container.textContent).not.toContain("This connection can’t be reconnected"); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(listConnectionsMock).toHaveBeenCalledTimes(2); expect(listApplicationsMock).toHaveBeenCalledTimes(2); expect(container.textContent).not.toContain("Couldn’t load connection setup"); expect(container.textContent).toContain("Connect Notion to Paperclip"); }, ); it("creates a fresh Notion OAuth connection when the provider landing requests another", async () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: "conn-existing", applicationId: "app-notion", authKind: "oauth", status: "active", config: { sourceTemplateKey: "notion" }, transportConfig: {}, }, { id: "conn-other-draft", applicationId: "app-other", authKind: "oauth", status: "draft", config: { sourceTemplateKey: "notion" }, transportConfig: {}, }], }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-new", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-new" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://mcp.notion.com/authorize?state=new" }, }); await render(); expect(connectAppMock).not.toHaveBeenCalled(); await passAccessStep(); await submitCuratedOAuthSetup(); expect(startOAuthMock).not.toHaveBeenCalled(); expect(connectAppMock).toHaveBeenCalledWith("company-1", { galleryKey: "notion", connectionMethodKey: "mcp-oauth", name: "Notion for the organization", credentialSource: "paperclip_vault", credentialValues: {}, configValues: undefined, applicationId: "app-notion", }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=new", ); }); it("waits for fresh connection data before creating a Notion OAuth draft", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.tools.applications("company-1"), { applications: [] }); queryClient.setQueryData(queryKeys.tools.connections("company-1"), { connections: [] }); let resolveApplications!: (value: unknown) => void; let resolveConnections!: (value: unknown) => void; listApplicationsMock.mockReturnValueOnce(new Promise((resolve) => { resolveApplications = resolve; })); listConnectionsMock.mockReturnValueOnce(new Promise((resolve) => { resolveConnections = resolve; })); await render(queryClient); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); resolveApplications({ applications: [notionApplication("draft")], }); resolveConnections({ connections: [notionConnection("conn-refreshed", "per_user", "draft")], }); await flushReact(); await flushReact(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("Reconnect keeps this identity type"); expect(container.textContent).toContain("agent access stays as it is"); await passAccessStep(); await submitCuratedOAuthSetup(); expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", { asCurrentUser: true, }); }); it("resumes Notion OAuth after a failed connection lookup is retried", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock .mockRejectedValueOnce(new Error("Lookup unavailable")) .mockResolvedValueOnce({ applications: [notionApplication("draft")], }); listConnectionsMock .mockResolvedValueOnce({ connections: [] }) .mockResolvedValueOnce({ connections: [notionConnection("conn-after-retry", "per_user", "draft")], }); await render(); expect(container.textContent).toContain("couldn’t check for an existing connection"); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); // PAP-659: Try again re-reads and then resumes the draft it finds. There // is no Access step to bounce back through, and the operator already // pressed the one primary action, so recovery must not ask again. expect(connectAppMock).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("Reconnect keeps this identity type"); expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", { asCurrentUser: true, }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=resumed", ); }); it("restores the Notion lookup error when retrying still fails", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockRejectedValue(new Error("Lookup unavailable")); await render(); await passAccessStep(); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("couldn’t check for an existing connection"); expect(buttonByText("Try again")).toBeTruthy(); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); it("retries OAuth on the prepared connection without creating another draft", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-prepared", application: { id: "app-notion", name: "Notion" }, connection: { id: "conn-prepared", credentialPolicy: "per_user", status: "draft" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); startOAuthMock .mockRejectedValueOnce(new Error("Provider unavailable")) .mockResolvedValueOnce(oauthStartResponse("conn-prepared", "notion", "https://mcp.notion.com/authorize?state=retry")); await render(); await passAccessStep(); await submitCuratedOAuthSetup(); expect(container.textContent).toContain("Provider unavailable"); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(startOAuthMock).toHaveBeenCalledTimes(2); expect(startOAuthMock).toHaveBeenLastCalledWith("conn-prepared", { asCurrentUser: true, }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=retry", ); }); it("recovers a response-lost Notion draft before retrying creation", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock .mockResolvedValueOnce({ applications: [] }) .mockResolvedValueOnce({ applications: [{ id: "app-response-lost", status: "draft", metadata: { sourceTemplateKey: "notion" }, }], }); listConnectionsMock .mockResolvedValueOnce({ connections: [] }) .mockResolvedValueOnce({ connections: [{ id: "conn-response-lost", applicationId: "app-response-lost", authKind: "oauth", credentialPolicy: "per_user", status: "draft", config: { sourceTemplateKey: "notion" }, transportConfig: {}, }], }); connectAppMock.mockRejectedValueOnce(new Error("Response lost")); startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-response-lost", "notion", "https://mcp.notion.com/authorize?state=recovered")); await render(); await passAccessStep(); await submitCuratedOAuthSetup(); expect(container.textContent).toContain("Response lost"); await act(async () => { buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(startOAuthMock).toHaveBeenCalledWith("conn-response-lost", { asCurrentUser: true, }); expect(navigateTopLevelMock).toHaveBeenCalledWith( "https://mcp.notion.com/authorize?state=recovered", ); }); it("opens customer-owned OAuth apps instead of blocking them by slug", async () => { const slack = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "slack")!; mockParams.appKey = "slack"; listGalleryMock.mockResolvedValueOnce({ apps: [slack] }); await render(); expect(connectAppMock).not.toHaveBeenCalled(); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); it("routes the enabled Notion gallery tile through the generic source deep link", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); await render(); const notionTile = buttonContaining("Notion"); expect(notionTile?.disabled).toBe(false); expect(notionTile?.textContent).toContain("Connect"); await act(async () => { notionTile?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion"); }); it("choosing No and clicking Check link connects with no credentials", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "example.com/actions for the organization", }); expect(input.credentialValues).toBeUndefined(); }); it("reveals one masked key field only after the probe says a credential is needed", async () => { // PAP-659 bucket H. The first press asks the server; the server is what // knows. Only its credential challenge puts a key field on screen, and the // second press carries the key. connectAppMock.mockRejectedValueOnce( new ApiError("This app needs you to sign in.", 502, { error: "This app needs you to sign in.", details: { code: "oauth_challenge" }, }), ); await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); expect( Array.from(container.querySelectorAll("input")).some( (i) => i.type === "password", ), ).toBe(false); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("This server wants a credential"); const passwordInputs = Array.from( container.querySelectorAll("input"), ).filter((i) => i.type === "password"); expect(passwordInputs).toHaveLength(1); expect(container.textContent).not.toContain("Stored securely."); await act(async () => setInputValue(passwordInputs[0], "secret-key")); await flushReact(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(2); const [, input] = connectAppMock.mock.calls[1]; expect(input.credentialValues).toEqual({ "credentials.authorization": "secret-key" }); }); it("a Zapier MCP URL stays in the URL flow and includes its token in the submitted link", async () => { await render(); const linkInput = Array.from( container.querySelectorAll("input"), ).find((i) => i.getAttribute("placeholder")?.startsWith("https://")); const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token"; await act(async () => setInputValue(linkInput!, zapierUrl)); await flushReact(); expect(container.textContent).toContain("This looks like Zapier."); expect(linkInput?.type).toBe("password"); expect(linkInput?.autocomplete).toBe("off"); await act(async () => { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Which agents can use this connection?"); await passAccessStep(); expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).not.toContain(zapierUrl); expect(container.textContent).toContain("token=REDACTED"); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(container.querySelector('input[type="password"]')).toBeNull(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier for the organization", galleryKey: "zapier", connectionMethodKey: "generated-url", }); expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined(); }); it("gives Zapier the shared credential and agent access opener", async () => { mockSearch.value = "source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ { ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } }, ], }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-1", application: { id: "app-1", name: "Zapier" }, actions: { readOnly: [ { catalogEntryId: "action-1", toolName: "find_record", title: "Find record", description: "Find a record.", riskLevel: "read", }, ], canMakeChanges: [ { catalogEntryId: "action-2", toolName: "create_record", title: "Create record", description: "Create a record.", riskLevel: "write", }, ], }, catalog: [{ id: "action-1", status: "active" }, { id: "action-2", status: "active" }], suggestedDefaults: { askFirstRiskLevels: [] }, }); await render(); // PAP-659 C0: no step counter and no Access step — the endpoint field is // the first and only thing on the way in, with the default stated above it. expect(container.textContent).not.toContain("Step 1 of 2"); expect(container.textContent).not.toContain("Step 2 of 2"); expect(container.textContent).toContain("Connect Zapier"); expect(container.textContent).toContain("Connects for everyone in your organization, available to all agents."); expect(container.querySelector('[data-remote-mcp-provider="zapier"]')).toBeTruthy(); expect(container.textContent).not.toContain("Pick the app you want your agents to use."); expect(container.textContent).toContain("MCP server URL"); // The two questions the deleted step asked are still answerable, together, // one disclosure away. expect(container.textContent).not.toContain("Which humans can use this credential?"); await act(async () => buttonByText("Change")!.click()); expect(container.textContent).toContain("Which humans can use this credential?"); expect(container.textContent).toContain("Which agents can use this connection?"); await act(async () => { radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("Connects as you, available to all agents."); const linkInput = container.querySelector( 'input[placeholder="Paste the full URL from Zapier"]', ); expect(linkInput?.type).toBe("password"); expect(linkInput?.autocomplete).toBe("off"); const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token"; await act(async () => setInputValue(linkInput!, zapierUrl)); await flushReact(); await act(async () => { buttonByText("Connect Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier", galleryKey: "zapier", connectionMethodKey: "generated-url", grantKind: "user", }); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["action-1", "action-2"], askFirstCatalogEntryIds: [], access: "all_agents", }); expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [ { targetType: "company", targetId: "company-1" }, ]); }); it("keeps alternate setup methods out of the connection wizard", async () => { await render(); expect(container.textContent).not.toContain("More ways to connect"); expect(container.textContent).not.toContain("Run your own"); expect(container.textContent).not.toContain("Paste a config"); }); // PAP-11091: discoverability copy for remote MCP URLs — the link field // advertises that any remote tool URL (incl. a local MCP server) works. Per // the UX re-review, the localhost example lives in the body copy (legible at // every viewport) rather than the placeholder, which truncated on mobile. it("advertises that remote/local MCP URLs work under 'Connect with a link'", async () => { await render(); expect(container.textContent).toContain( "Any remote tool URL works here — including a local MCP server like", ); expect(container.textContent).toContain("http://127.0.0.1:8848/mcp"); const linkInput = Array.from(container.querySelectorAll("input")).find((i) => i.getAttribute("placeholder")?.startsWith("https://example.com/actions"), ); // Placeholder stays a single, short example so it never truncates. expect(linkInput?.getAttribute("placeholder")).toBe("https://example.com/actions"); }); // Reconnect from the app page: ?link/?name/?applicationId prefill skips the // gallery and re-attaches the connection to the existing application. it("prefills the link frame from search params and passes applicationId to connect", async () => { mockSearch.value = "link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77"; await render(); await openAccessAdvanced(); expect(container.textContent).toContain("Which humans can use this credential?"); await passAccessStep(); expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).toContain("https://www.example.com/actions"); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "Bla for the organization", applicationId: "app-77", }); }); it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); await act(async () => { buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("Share each sheet with this email"); expect(container.textContent).toContain("robot@paperclip.iam.gserviceaccount.com"); expect(container.textContent).toContain( "In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.", ); expect(buttonByText("Connect")?.disabled).toBe(true); }); it("shows inline validation for invalid Google Sheets links", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); await act(async () => { buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const textarea = container.querySelector("textarea"); await act(async () => setTextareaValue(textarea!, "https://example.com/not-a-sheet")); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.textContent).toContain("That doesn't look like a Google Sheets link."); expect(connectAppMock).not.toHaveBeenCalled(); }); it("gallery setup names the connection automatically", async () => { await render(); await act(async () => { buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await passAccessStep(); expect(container.textContent).toContain("Connect GitHub"); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github"); }); it("keeps the originating connection intent in wizard URLs", async () => { const interactionId = "11111111-1111-4111-8111-111111111111"; mockSearch.value = `intent=${interactionId}`; await render(); await act(async () => { buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockNavigate).toHaveBeenCalledWith( `/apps/connect?source=github&intent=${interactionId}`, ); await passAccessStep(); expect(mockNavigate).not.toHaveBeenCalledWith("/apps"); }); it("steps back from the single connect screen straight to Connectors", async () => { mockSearch.value = ""; mockParams.appKey = "github"; await render(); await passAccessStep(); expect(container.textContent).toContain("Connect GitHub"); // PAP-659: there is no Access step to return to, so Back is a single hop // out of the connector rather than a two-hop wizard retreat. await act(async () => { buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockNavigate).toHaveBeenCalledWith("/apps"); expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect?byo=1"); }); it("leaving the default name connects with the app name", async () => { await render(); await act(async () => { buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await passAccessStep(); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "secret-key")); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github"); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "github", name: "GitHub for the organization" }); }); it("continues an exact credential-based draft instead of creating a replacement", async () => { const connectionId = "33333333-3333-4333-8333-333333333333"; mockSearch.value = `source=github&resume=${connectionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB] }); listApplicationsMock.mockResolvedValueOnce({ applications: [{ id: "app-github", status: "active", metadata: { sourceTemplateKey: "github" } }], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ id: connectionId, applicationId: "app-github", name: "Engineering GitHub for the organization", authKind: "api_key", credentialPolicy: "shared", status: "draft", config: { sourceTemplateKey: "github", connectionMethodKey: "mcp-key" }, transportConfig: {}, }], }); await render(); await passAccessStep(); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "replacement-key")); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ galleryKey: "github", connectionMethodKey: "mcp-key", resumeConnectionId: connectionId, name: "Engineering GitHub for the organization", credentialValues: { "credentials.authorization": "replacement-key" }, }); }); it("uses the Google Sheets app name automatically", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); await act(async () => { buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const textarea = container.querySelector("textarea"); await act(async () => setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"), ); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "google-sheets", name: "Google Sheets for the organization", configValues: { allowedSpreadsheetIds: ["sheet_123"] }, }); }); it("passes parsed Google Sheets IDs as connection config values", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); await act(async () => { buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await openAccessAdvanced(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const textarea = container.querySelector("textarea"); await act(async () => setTextareaValue( textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit\nhttps://docs.google.com/spreadsheets/d/sheet_456", ) ); await flushReact(); await act(async () => { buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "google-sheets", configValues: { allowedSpreadsheetIds: ["sheet_123", "sheet_456"] }, }); }); }); /** * PAP-17087 — the BYO URL card is now the guided universal flow. What matters is * that the simple path stayed simple, that each failure mode names the thing the * operator has to change, and that a pasted endpoint that needs sign-in actually * gets there instead of a "coming soon" toast. */ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { let container: HTMLDivElement; let mountedRoot: Root | null; beforeEach(() => { mockSearch.value = ""; mockParams.appKey = undefined; container = document.createElement("div"); document.body.appendChild(container); mountedRoot = null; listGalleryMock.mockResolvedValue({ apps: [ZAPIER] }); listApplicationsMock.mockResolvedValue({ applications: [] }); listConnectionsMock.mockResolvedValue({ connections: [] }); listAgentsMock.mockResolvedValue([]); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); startOAuthMock.mockResolvedValue(oauthStartResponse("conn-1", "mcp_example_test", "https://auth.example.test/authorize?state=abc")); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, }); }); afterEach(async () => { if (mountedRoot) { await act(async () => mountedRoot?.unmount()); } document.body.removeChild(container); document.body.innerHTML = ""; vi.restoreAllMocks(); vi.clearAllMocks(); }); async function render() { const root = createRoot(container); mountedRoot = root; const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render( , ); }); await flushReact(); await flushReact(); return root; } async function openAdvanced() { await act(async () => { buttonContaining("Advanced authentication")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); } // Production branches on `error instanceof ApiError`, so the double has to be a // real one — a look-alike would silently fall through to the generic message and // make these tests pass for the wrong reason. function apiError(status: number, code: string, message: string) { return new ApiError(message, status, { error: message, details: { code } }); } it("defaults the hidden connection name from host, port, and path", async () => { await render(); await gotoLinkFrame(container, "http://127.0.0.1:47399/mcp"); expect(container.querySelector("#generic-mcp-name")).toBeNull(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ name: "127.0.0.1:47399/mcp for the organization", }); }); it("keeps the endpoint host visible while skipping action review", async () => { await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); expect(container.textContent).toContain("Unverified server"); expect(container.textContent).toContain("mcp.example.test"); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, actions: { readOnly: [{ catalogEntryId: "cat-read", toolName: "list_things", title: "List things", description: null, riskLevel: "read", isReadOnly: true, isWrite: false, isDestructive: false, status: "active", }, { catalogEntryId: "cat-search", toolName: "search_things", title: "Search things", description: null, riskLevel: "read", isReadOnly: true, isWrite: false, isDestructive: false, status: "active", }], canMakeChanges: [{ catalogEntryId: "cat-delete", toolName: "qa_delete_widget", title: null, description: null, riskLevel: "destructive", isReadOnly: false, isWrite: true, isDestructive: true, status: "active", }], }, catalog: [], suggestedDefaults: { askFirstRiskLevels: [] }, }); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // The completion summary states identity and reach once each, as three // lines rather than badges, and still never lists the actions. expect(container.textContent).toContain("mcp.example.test is ready."); expect(container.textContent).toContain("Organization identity"); expect(container.textContent).toContain("Any agent"); expect(container.textContent).toContain("3 actions on"); expect(container.textContent).not.toContain("List things"); expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0); }); it("offers a curated setup as a convenience without leaving the generic flow", async () => { await render(); await gotoLinkFrame(container, "https://mcp.zapier.com/api/v1/connect?token=t"); // Both routes are present: the branded shortcut and the generic form itself. expect(container.textContent).toContain("Paperclip has a guided setup for Zapier."); expect(container.textContent).toContain("Connect your own MCP server"); expect(buttonByText("Check link")).toBeTruthy(); }); it("explains a private-network address instead of blaming the key", async () => { connectAppMock.mockRejectedValue( apiError(400, "remote_http_private_endpoint", "Remote MCP connection URL cannot target private or reserved network addresses"), ); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("That address is inside a private network"); // Still on the setup screen with the address in hand, not bounced back. expect(buttonByText("Check link")).toBeTruthy(); }); it("explains an unreachable host", async () => { connectAppMock.mockRejectedValue(apiError(400, "remote_http_dns_failed", "hostname could not be resolved")); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("We couldn't find that host"); }); it("uses deployment guidance without rendering the server env-var message", async () => { connectAppMock.mockRejectedValue(apiError( 422, "oauth_redirect_origin_unsupported", "OAuth connections require PAPERCLIP_PUBLIC_URL or an auth public base URL", )); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("This Paperclip needs a public HTTPS address first"); expect(container.textContent).not.toContain("PAPERCLIP_PUBLIC_URL"); }); it("does not ask the operator to resolve an internal name conflict", async () => { connectAppMock.mockRejectedValue(apiError( 409, "tool_access_name_conflict", "A tool access record with that name already exists", )); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("Paperclip couldn’t name this connection"); expect(container.textContent).not.toContain("Choose a different name"); expect(container.querySelector("#generic-mcp-name")).toBeNull(); }); it("opens advanced authentication when the server wants a credential we can't discover", async () => { connectAppMock.mockRejectedValue(apiError(502, "oauth_challenge", "This app needs you to sign in.")); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("This server wants a credential"); // The advanced section is now open, so the fields to fix it are on screen. expect(container.textContent).toContain("Custom headers"); expect(container.textContent).not.toContain("coming soon"); }); it("sends the operator to sign-in when the endpoint needs browser authorization", async () => { connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://auth.example.test/authorize?state=abc" }, }); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(navigateTopLevelMock).toHaveBeenCalledWith("https://auth.example.test/authorize?state=abc"); // Residual risk of a real-but-hostile authorization page: name the host the // operator is being handed to (PAP-17099). expect(container.textContent).toContain("auth.example.test"); }); // The curated flow has its own regression test for this. The generic flow is // a separate two-step model with a separate override, so it needs its own — // otherwise the waiting screen could quietly drop back to the curated // single-step label here and nothing would catch it. it("keeps the generic two-step model when a pasted endpoint hands off to sign-in", async () => { connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://auth.example.test/authorize?state=abc" }, }); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); const stepsBeforeHandoff = stepLabelsOnScreen(); expect(stepsBeforeHandoff).toEqual(["Pick app", "Add your key"]); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); // Signing in must neither grow the stepper nor shrink it to the curated // two-step set the pasted endpoint never walked. expect(stepLabelsOnScreen()).toEqual(stepsBeforeHandoff); expect(stepDotCount()).toBe(stepsBeforeHandoff.length); }); it("exchanges a managed connect response in the tenant without opening Cloud confirmation", async () => { const session = "managed_background_session_1234"; const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => input === "/cloud/connections/handoff" ? Response.json({ authorizationUrl: "https://provider.example.test/authorize?state=managed" }) : new Response(null, { status: 404 })); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, connection: { id: "conn-1", credentialPolicy: "shared", status: "draft" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: "https://my.paperclip.app/connections/confirm?session=legacy", handoff: { kind: "paperclip_cloud", session }, }, }); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); 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=managed", ); }); expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); }); /** * PAP-17099 — a generic MCP server picks its own authorization endpoint, and * `window.location.assign` is where an unsafe scheme would actually execute. * The board refuses independently of the API response. */ describe("unsafe authorization urls", () => { const UNSAFE = [ ["javascript:", "javascript:fetch('https://evil.test/'+document.cookie)"], ["data:", "data:text/html,"], ["file:", "file:///etc/passwd"], ["plaintext http", "http://evil.test/authorize"], ["credentials", "https://auth.example.test@evil.test/authorize"], ] as const; it.each(UNSAFE)("never opens a %s start url from connect", async (_label, startUrl) => { connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, connection: { id: "conn-1", credentialPolicy: "shared", status: "draft" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl }, }); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("couldn’t connect"); // The refusal is explained without echoing the hostile address on screen. expect(container.textContent).not.toContain(startUrl); expect(container.textContent).toMatch(/sign-in address/); }); it.each(UNSAFE)("never opens a %s authorization url from start sign-in", async (_label, authorizationUrl) => { connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, connection: { id: "conn-1", credentialPolicy: "shared", status: "draft" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); startOAuthMock.mockResolvedValue(oauthStartResponse("conn-1", "mcp_example_test", authorizationUrl)); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: false, }); expect(navigateTopLevelMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("couldn’t connect"); expect(container.textContent).not.toContain(authorizationUrl); // Retry is still offered rather than a dead end. expect(buttonByText("Try again")).toBeTruthy(); }); }); it("asks for a preregistered client rather than losing the draft", async () => { connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null, manualClientRequired: true }, }); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("This server needs sign-in details you create yourself"); expect(container.textContent).toContain("Client ID"); expect(container.textContent).toContain("Client secret"); // No redirect happened: there is nothing to redirect to yet. expect(navigateTopLevelMock).not.toHaveBeenCalled(); }); it("submits custom headers as secret-backed credential values", async () => { await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await openAdvanced(); await act(async () => { buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const nameInput = Array.from(container.querySelectorAll("input")) .find((input) => input.getAttribute("aria-label") === "Header name")!; await act(async () => setInputValue(nameInput, "X-Api-Key")); await flushReact(); const valueInput = Array.from(container.querySelectorAll("input")) .find((input) => input.type === "password")!; await act(async () => setInputValue(valueInput, "phx_secret")); await flushReact(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(connectAppMock).toHaveBeenCalledTimes(1); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ link: "https://mcp.example.test/mcp", authMode: "custom_headers", credentialValues: { "headers.X-Api-Key": "phx_secret" }, }); }); it("blocks a header Paperclip refuses to send before making a request", async () => { await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await openAdvanced(); await act(async () => { buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const nameInput = Array.from(container.querySelectorAll("input")) .find((input) => input.getAttribute("aria-label") === "Header name")!; await act(async () => setInputValue(nameInput, "Host")); await flushReact(); const valueInput = Array.from(container.querySelectorAll("input")) .find((input) => input.type === "password")!; await act(async () => setInputValue(valueInput, "evil.example")); await flushReact(); expect(container.textContent).toContain('Paperclip manages the "Host" header'); expect(buttonByText("Check link")?.disabled).toBe(true); expect(connectAppMock).not.toHaveBeenCalled(); }); it("sends preregistered client credentials when the operator supplies them", async () => { await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await openAdvanced(); await act(async () => { buttonByText("Browser sign-in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const clientIdInput = container.querySelector("#generic-mcp-client-id")!; await act(async () => setInputValue(clientIdInput, "operator-client")); await flushReact(); const clientSecretInput = container.querySelector("#generic-mcp-client-secret")!; await act(async () => setInputValue(clientSecretInput, "operator-secret")); await flushReact(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ authMode: "oauth", oauthClient: { clientId: "operator-client", clientSecret: "operator-secret" }, }); }); it("keeps protocol jargon off the consumer path", async () => { await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await openAdvanced(); for (const jargon of ["DCR", "Dynamic Client Registration", "CIMD", "Client ID Metadata", "RFC", "PKCE"]) { expect(container.textContent, jargon).not.toContain(jargon); } }); });