Files
PaperClipAI/ui/src/pages/apps/AppsConnect.test.tsx
T
DottaandPaperclip 0726a00e80 feat(apps): qualify Bitly, Context7, Hugging Face and Resend MCP (#15686)
## Thinking Path

> - Paperclip lets people manage AI agents and govern their tools.
> - Apps already supports provider-hosted MCP servers.
> - Useful providers still need real setup and read proof before Browse
exposure.
> - Browser testing found a Bitly token-format failure and an OAuth
callback that replaced an explicit empty agent selection.
> - This PR qualifies four provider methods and fixes those shared OAuth
paths.
> - Providers that still need login or approval remain withheld from new
Browse listings.

## Linked Issues or Issue Description

Refs: #15621, the merged hosted MCP qualification batch. Searches found
no duplicate Bitly or Context7 implementation PR.

**What existing behavior does this improve?**

Apps setup, provider MCP discovery, token exchange, and saved agent
access through an OAuth callback.

**Subsystem affected**

Shared Apps definitions, the server tool-access service, and the UI
setup/recovery route.

**Current behavior**

Bitly's callback can fail with `oauth_access_token_missing` when its
token response is not JSON. Curated MCP callbacks can treat an
explicitly saved empty install set as access for all agents. Context7 is
hidden and Hugging Face lacks a curated anonymous method. Saved drafts
for hidden providers cannot finish setup through their catalog wizard.

**Proposed behavior**

Request JSON token responses. Record explicit remote MCP install choices
in the install transaction and preserve them at callback. Keep managed
subject/default access when no choice was saved. Expose the qualified
Bitly and Context7 methods, add anonymous Hugging Face Hub access, and
record Resend qualification. Resume hidden providers only through an
exact matching company-visible saved draft.

**Reason and benefit**

People can connect useful hosted tools through the existing Apps flow.
An explicit zero-agent selection survives authorization. Public
documentation lookups need no OAuth app, account, or key.

**Breaking changes**

Explicitly saved empty MCP install choices now remain empty. Existing
managed defaults without a saved choice retain their behavior. No schema
migration or API shape change.

## What Changed

- Qualify Bitly OAuth, Context7 public MCP, Hugging Face public MCP, and
Resend OAuth with real browser setup, useful UI reads, and reload
checks.
- Negotiate JSON OAuth token responses and preserve explicitly saved MCP
agent reach with a transactional config marker.
- Add durable provider overrides, permission reviews, dated text-only
evidence, and official branding provenance. Remove all seven evidence
images and their report references.
- Restore Finish setup for hidden-provider drafts with
company/application/provider checks; keep fresh setup withheld. Preserve
success after the exact resumed draft becomes active, and restore valid
completed URLs to that exact saved Permissions page; reject initially
active, disabled, or archived resume targets. Align the Context7 route
assertion with its new qualified availability.
- Retain hidden ClickUp, Klaviyo, and monday.com research with exact
login/approval gates. monday.com requires provider callback
allowlisting.

## Verification

- Connector service suite: 420 passed, including
default/empty/agent/company reach and managed OAuth lifecycle coverage.
- Shared catalog: 36 passed. Override tests: 3 passed. Brand checks: 101
identities passed.
- Focused UI setup/recovery, Browse, and OAuth-helper suites: 306
passed. Actual hidden-draft resume/reload passed; a public Context7
draft seeded in the isolated app completed through the active-state
refetch and persisted with zero agents after reload. The completed URL
refresh and View connection open the exact saved Permissions page. Fresh
hidden setup, normal initially active resume, disabled/archived status,
and provider mismatch are rejected; a draft at the completion stage
stays in setup.
- Recursive typecheck and build passed again after rebase. The full
local Vitest run began before rebase and was cancelled as superseded
while still in the general-server phase; it is not a completed pass.
Exact-head CI supplies broad coverage. The focused connector service
suite passed against the final code. UI typecheck, build, and token
gates passed again after the draft-recovery fix.
- Targeted mock MCP Playwright journey: 1 passed (34.9s). It now
verifies View connection reaches the exact saved Permissions route, both
discovered actions, and reload persistence. This replaces the stale
Browse expectation found by CI.
- All CI gates passed on final commit
`32bb4c6422411eee632486950e45e2735102d1b3`. The inherited-pause test
timed out in the cleanup run and passed on one retry without code
changes; the entire file also passed locally, 36/36. Its exact timing
cause remains unresolved. A signoff timeout on the preceding
production-code commit also passed on its single retry. Greptile
reviewed the final commit at 5/5 with no open findings.
- Actual UI reads: Bitly current-user read; Resend List Domains
including a fresh setup after the final callback refinement; Context7
React docs; Hugging Face public docs search. No provider writes or real
agent runtimes ran.
- [Text-only qualification
report](https://github.com/paperclipai/paperclip/blob/32bb4c6422411eee632486950e45e2735102d1b3/doc/connections/easy-mcp-batch-2-2026-10-09.md)
include evidence limits and exact blockers. Account/profile/domain
results are omitted.

## Risks

- Bitly `mcp.all` and Resend `full_access` remain broad provider grants.
Qualification kept writes Off and selected no agents. Read proof does
not establish write behavior or refresh rotation.
- The explicit-choice marker applies to new install saves; older
unmarked flows keep their existing defaults. Its JSONB merge avoids
overwriting concurrent config fields.
- Public provider catalogs and rate limits can change. Unqualified
providers remain withheld from new Browse entries; retained connections
remain available for recovery.

## Model Used

OpenAI Codex, based on GPT-6, plus OpenAI `gpt-6-luna` subagents for
provider research, browser testing, and review. The exact primary
serving variant and context-window size are not exposed by this runtime.
Used reasoning, code execution, and browser tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 11:52:33 -05:00

4555 lines
199 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<HTMLButtonElement>('[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<HTMLElement>('[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<HTMLInputElement>("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();
}
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({
connectionId: "conn-notion",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=resumed",
expiresAt: "2099-01-01T00:00:00.000Z",
});
finishAppMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
getCloudConnectorEnrollmentMock.mockResolvedValue({
configured: true,
status: "active",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: "inst-test",
environment: "staging",
origins: ["https://paperclip.example.test"],
});
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(
<QueryClientProvider client={client}>
<TooltipProvider>{content ?? <AppsConnect byoOnly={byoOnly} />}</TooltipProvider>
</QueryClientProvider>,
);
});
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, <ConnectionSetupFlow serviceSlug="honcho" />);
const key = container.querySelector<HTMLInputElement>('input[type="password"]')!;
const workspace = container.querySelector<HTMLInputElement>('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, <ConnectionSetupFlow serviceSlug="notion" />);
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, <ConnectionSetupFlow host="dialog" serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} />);
// 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<HTMLInputElement>('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<HTMLSelectElement>("select")!;
auth.value = "bearer";
auth.dispatchEvent(new Event("change", { bubbles: true }));
});
const token = container.querySelector<HTMLInputElement>('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, <ConnectionSetupFlow host="dialog" serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} onPhaseChange={onPhaseChange} />);
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<HTMLInputElement>('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, <ConnectionSetupFlow host="dialog" serviceSlug="composio" interactionId="intent-inline" onPhaseChange={onPhaseChange} onComplete={onComplete} />);
await act(async () => buttonByText("Connect Composio")!.click());
await vi.waitFor(() => expect(container.textContent).toContain("Paperclip couldn’t open sign-in"));
const link = container.querySelector<HTMLAnchorElement>('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<HTMLInputElement>('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<HTMLAnchorElement>('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 = <ConnectionSetupFlow host="dialog" serviceSlug="zapier" requestedAgentId="agent-1" interactionId="intent-inline" onCancel={onCancel} />;
const root = await render(undefined, false, content);
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('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, <ConnectionSetupFlow host={host} serviceSlug="zapier" onCancel={onCancel} />);
await passAccessStep();
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('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, <ConnectionSetupFlow host="dialog" serviceSlug="composio" requestedAgentId="agent-1" interactionId="intent-inline" existingConnections={[{ id: "existing", applicationId: "app", name: "Existing Composio", status: "active", enabled: true }]} onUseExisting={onUseExisting} />);
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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, <ConnectionSetupFlow host="dialog" configuredConnection={choice} requestedAgentId="agent-1" />);
const input = container.querySelector<HTMLInputElement>('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, <ConnectionSetupFlow host="dialog" configuredConnection={choice} requestedAgentId="agent-1" />);
const input = container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>('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<HTMLInputElement>('input[placeholder="Search apps…"]')!, "no matching cards"));
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('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 ? <ConnectionSetupFlow host="dialog" serviceSlug="anthropic" interactionId="ai-intent" requestedAgentId="agent-1" aiConnection={{ provider: "anthropic", method: "subscription", mode: "responsible_user" }} /> : 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<HTMLInputElement>('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<HTMLButtonElement>('[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<HTMLInputElement>("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<HTMLInputElement>("#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<HTMLInputElement>("#curated-oauth-client-id")?.value).toBe("saved-asana-client");
expect(buttonByText("Continue to sign in")?.disabled).toBe(!hasSavedOAuthClientSecret);
await act(async () => { setInputValue(container.querySelector<HTMLInputElement>("#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<HTMLInputElement>("#curated-oauth-client-id")!;
const clientSecret = container.querySelector<HTMLInputElement>("#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<HTMLInputElement>("#curated-oauth-client-id");
const clientSecret = container.querySelector<HTMLInputElement>("#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: [{
...GMAIL,
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
getCloudConnectorEnrollmentMock.mockResolvedValueOnce({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: null,
environment: "staging",
origins: [],
});
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: [{
...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}] });
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" });
await render(client, false, <ConnectionSetupFlow host="dialog" serviceSlug="gmail" interactionId="intent-1" requestedAgentId="agent-1" />);
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<HTMLAnchorElement>('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: [{
...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}] });
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, <ConnectionSetupFlow host="dialog" serviceSlug="gmail" interactionId="intent-1" requestedAgentId="agent-1" />);
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: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
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: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
getCloudConnectorEnrollmentMock.mockResolvedValueOnce({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: null,
environment: "staging",
origins: [],
});
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: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
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: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
getCloudConnectorEnrollmentMock
.mockRejectedValueOnce(new Error("Cloud unavailable"))
.mockResolvedValueOnce({
configured: true,
status: "active",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: "inst-test",
environment: "staging",
origins: ["https://paperclip.example.test"],
});
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: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
capabilities: {
canCreateOrganizationGrant: true,
organizationGrantReason: null,
canSetCompanyInstall: true,
companyInstallReason: null,
},
});
getCloudConnectorEnrollmentMock.mockResolvedValue({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: null,
environment: "staging",
origins: [],
});
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<HTMLElement>('[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({
configured: true,
status: "active",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: "inst-test",
environment: "staging",
origins: ["https://paperclip.example.test"],
});
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(
<QueryClientProvider client={coldLoadClient}>
<TooltipProvider><AppsConnect /></TooltipProvider>
</QueryClientProvider>,
);
});
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: [{
...GMAIL,
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[type="password"]');
expect(keyInput).toBeTruthy();
expect(container.textContent).toContain("Pin to project ID");
expect(container.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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, <AppsConnect credentialSource="vercel_connect" />);
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<HTMLInputElement>('input[type="password"]')).toBeNull();
const connectorInput = container.querySelector<HTMLInputElement>('#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, <AppsConnect credentialSource="vercel_connect" />);
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 methodState = () => ({
oauth: buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed"),
apiKey: buttonByText("Use a personal API key")?.getAttribute("aria-pressed"),
connectDisabled: buttonByText("Connect")?.disabled,
});
mockParams.appKey = "posthog";
listGalleryMock.mockResolvedValue({ apps: [POSTHOG] });
const pageRoot = await render();
await passAccessStep();
const pageMethods = methodState();
await act(async () => pageRoot.unmount());
container.innerHTML = "";
mockParams.appKey = undefined;
const dialogRoot = await render(undefined, false, (
<ConnectionSetupFlow
host="dialog"
serviceSlug="posthog"
requestedAgentId="agent-1"
/>
));
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 act(async () => {
continueButton?.click();
});
await flushReact();
expect(methodState()).toEqual(pageMethods);
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, (
<ConnectionSetupFlow
host="dialog"
serviceSlug="notion"
requestedAgentId="agent-1"
interactionId="interaction-1"
/>
));
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, <ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onComplete={onComplete} />);
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, (
<ConnectionSetupFlow
host="dialog"
serviceSlug="notion"
requestedAgentId="agent-1"
interactionId="interaction-1"
onPhaseChange={onPhaseChange}
/>
));
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<HTMLAnchorElement>('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("shows an in-flight state while Paperclip prepares Notion sign-in", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
await render();
await passAccessStep();
await submitCuratedOAuthSetup();
expect(container.textContent).toContain("Connect Notion");
expect(container.textContent).toContain("Preparing secure sign-in");
expect(container.textContent).toContain("Preparing…");
expect(connectAppMock).toHaveBeenCalledTimes(1);
});
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(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: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: "conn-existing",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
startOAuthMock.mockResolvedValueOnce({
connectionId: "conn-existing",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=existing",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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: [{
id: "app-notion",
status: "active",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [
{
id: "11111111-1111-4111-8111-111111111111",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "shared",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
},
{
id: "22222222-2222-4222-8222-222222222222",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
},
],
});
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({
connectionId,
provider: "clickup",
authorizationUrl: "https://app.clickup.com/api?state=resumed",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
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: [{
id: "app-notion",
status: "active",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: "conn-existing",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy,
status: "active",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
startOAuthMock.mockResolvedValueOnce({
connectionId: "conn-existing",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=existing",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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: [{
id: "app-notion",
status: "archived",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: "conn-archived",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "shared",
status: "archived",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
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({
connectionId: "conn-archived",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=revived",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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: [{
id: "app-notion",
status: "archived",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: "conn-archived",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "shared",
status: "archived",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
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: [{
id: "app-notion",
status: "archived",
metadata: { sourceTemplateKey: "notion" },
}],
};
const connections = {
connections: [{
id: "conn-archived",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "shared",
status: "archived",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
};
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: [{
id: "app-notion",
status: "active",
metadata: { sourceTemplateKey: "notion" },
}],
});
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: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
resolveConnections({
connections: [{
id: "conn-refreshed",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
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: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock
.mockResolvedValueOnce({ connections: [] })
.mockResolvedValueOnce({
connections: [{
id: "conn-after-retry",
applicationId: "app-notion",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
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({
connectionId: "conn-prepared",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=retry",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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({
connectionId: "conn-response-lost",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=recovered",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>(
'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<HTMLInputElement>("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<HTMLTextAreaElement>("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<HTMLInputElement>("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<HTMLInputElement>("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 gallery app name automatically", async () => {
await render();
await act(async () => {
buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await passAccessStep();
const keyField = container.querySelector<HTMLInputElement>("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);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({
galleryKey: "github",
name: "GitHub for the organization",
});
});
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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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({
connectionId: "conn-1",
provider: "mcp_example_test",
authorizationUrl: "https://auth.example.test/authorize?state=abc",
expiresAt: "2099-01-01T00:00:00.000Z",
});
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(
<QueryClientProvider client={client}>
<AppsConnect />
</QueryClientProvider>,
);
});
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,<script>alert(document.domain)</script>"],
["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({
connectionId: "conn-1",
provider: "mcp_example_test",
authorizationUrl,
expiresAt: "2099-01-01T00:00:00.000Z",
});
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<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("input"))
.find((input) => input.getAttribute("aria-label") === "Header name")!;
await act(async () => setInputValue(nameInput, "Host"));
await flushReact();
const valueInput = Array.from(container.querySelectorAll<HTMLInputElement>("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<HTMLInputElement>("#generic-mcp-client-id")!;
await act(async () => setInputValue(clientIdInput, "operator-client"));
await flushReact();
const clientSecretInput = container.querySelector<HTMLInputElement>("#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);
}
});
});