Files
PaperClipAI/ui/src/pages/apps/AppsConnect.test.tsx
T
DottaandPaperclip 100b364264 Preserve selected Google services when setup resumes (#18048)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connections control which external services an agent can use.
> - Google Workspace setup lets a person choose services and read or
write access.
> - A draft lost that choice after a declined or expired OAuth handoff.
> - Finish setup could then show all services with write access instead
of the saved narrow choice.
> - This pull request saves the exact draft choice without treating it
as granted access.
> - The benefit is safe setup recovery that preserves the person's
choice and existing account limits.

## Linked Issues or Issue Description

Refs #15545. The duplicate search found no open PR for Workspace draft
consent recovery. Related #18000 concerns satisfied gateway access
requests, not saved OAuth setup intent.

**What happened?**

A cold Workspace draft resumed with the new-account service defaults.
The selected request lived only in short-lived OAuth state. Denial
consumed that state. The picker ignored draft connections.

**Expected behavior**

Finish setup restores the same person's saved service choice. It does
not use another person's choice, a broad profile hint, or a credential
grant as draft intent. An old draft with no reliable choice asks the
person to choose services.

**Steps to reproduce**

1. Start a new Google Workspace connection. Select Gmail read-only. Turn
off Drive and Calendar.
2. Continue to Google, then decline authorization.
3. Close the setup page. Return through Finish setup.
4. Open the service picker. Before this fix, all three services can be
enabled with write access.

**Paperclip version or commit**

Reproduced in tests on master
`820c458465fa40137c81fe510f3e6dc573b4f895`.

**Deployment mode**

Authenticated and local source builds. The source regression uses real
PostgreSQL fixtures and the production React setup components. Live
provider acceptance of this repair is still pending deployment.

## What Changed

- Save validated, non-secret draft intent before managed or
customer-owned OAuth. Bind it to company, connection, actor, grant kind,
and subject.
- Restore that intent after denial, state expiry, and cold Finish setup.
Keep active reconnect bound to its saved grant. Do not change
new-account defaults.
- Preserve agent-to-human setup handoffs. Consume only the addressed
person's reviewed proposal. Keep other people's proposals.
- Lock and merge the current draft before saving intent. Return 409 if
its credential identity or method changed during setup.
- Preserve the latest saved choice across overlapping customer-owned
starts. Merge both current configuration records under short locks.
Reject stale client-binding changes. Keep each OAuth state's own
requested scopes.
- Add shared, service, handoff, picker, and cold-page regression tests.
Update the connector contract and existing feature plan.

- Route the default ungrouped test/release paths through six sequential
exact-cover chat partitions. Preserve remaining server/native coverage,
explicit grouped CI, selection-first execution and shared-database
paths.
- Correct the retention test driver so pending owned real I/O does not
advance logical deadlines. Pump zero-duration connection timers and add
a held-I/O regression; keep production scheduling and original timing
assertions unchanged.

The shared helper is needed by the server and picker to validate the
same narrow binding. It does not resolve credentials or grant access. No
production schema migration, new route, provider scope or deployment
configuration is added. The test-harness changes do not change
production timers.

## Verification

### Current candidate: source qualification passed; live acceptance
pending

- Current published candidate:
`82d13d4d698a5579378b5d506cb7a1f9cb64b5c8`, tree
`78d6a821ce53f481fe3e9991edce56f0acffb22f`. It combines the Workspace
repair with launcher commit `bf29c9` and retention-test commit `e25987`,
then integrates master `493e229d93ba7798e80152f4549e2e9177ce50ae`. The
integration brings 40 upstream commits across 713 files. One launcher
conflict keeps upstream validated CI selection and its selection-first
path, then applies six default chat partitions only without a selection.
The authored PR diff has 16 files, 833 additions and 100 deletions; it
adds no authored lockfile or `.github` changes. The public head is
confirmed at this revision. Fresh CI and review passed as recorded
below. Source qualification is complete using exact-tree full CI and the
local results below. The separate local whole command was interrupted by
host disk protection and is not called a pass. Live repaired-draft
acceptance remains pending deployment.
- Exact clean `82d13d4` passed one Node 24.19.0 builtin-only run: 41/41
cases (37 launcher and four CI-workflow cases), zero failures/skips.
HEAD, tree and relevant file hashes stayed unchanged and clean before,
at start and after. This proves launcher fixtures, not real chat
execution, current typecheck/build, the dependency-backed selection
graph, or full feature acceptance.
- The new default ungrouped whole command uses six sequential
exact-cover chat partitions, then the remaining server suites,
workspaces and serialized suites: 28 expected invocations. It preserves
all native suites, including upstream usage coverage. The plain
ungrouped release launch path also changes. Explicit grouped CI,
validated selection and shared-database execution stay unchanged.
Launcher hard RED had three expected body failures and two unchanged
passes; the repaired focused five and final 37 passed. A later 41-case
staged-merge pass is retained separately; the clean 82 run above is the
frozen proof.
- Fresh [CI run
41153113944](https://github.com/paperclipai/paperclip/actions/runs/41153113944)
completed successfully on October 31 at 01:14:53 UTC. Current head has
52 successful check runs, four conditional skips and no pending/failing
checks; legacy Snyk also passed. The skips are test-file selection, the
optional paired benchmark and two Storybook checks. The full
server/chat/workspace/serialized matrix, all eight e2e shards, normal
build, build-gap typecheck, Runner checks and final aggregate gates
passed. Build and all three chat job logs identify checkout
`e5e831fd3a8534d0d0f1e892118c0f8fbe12dd92`, merging this head into
`493e229d93`. Its Git tree is exactly
`78d6a821ce53f481fe3e9991edce56f0acffb22f`, identical to the clean
candidate. The six real chat partitions passed 314, 358, 273, 239, 321
and 311 cases: 1,816 total. Other cases shown as skipped in each
partition were selection exclusions, not extra passed cases. This is
current tree-equivalent CI proof, not a pass of the separate default
ungrouped local whole command.
- Fresh [Greptile
summary](https://github.com/paperclipai/paperclip/pull/18048#issuecomment-6473856992),
edited October 31 at 01:05:04 UTC, explicitly reviews exact head
`82d13d4` at 5/5 with no actionable code issue. Its exact-head check
passed. The prior overlapping-start discussion is resolved. This review
is source-only, not provider or live acceptance.
- Exact clean `82d13d4` passed full recursive typecheck and normal `pnpm
build` on the independent darwin/arm64 snapshot. Typecheck finished on
October 31 at 01:27:33 UTC; build finished at 01:28:33 UTC. Both exited
0. Normal native preparation and code signing passed. Source HEAD, tree
and tracked/untracked state stayed unchanged and clean. `pnpm
check:token-gates` also passed all token, palette and ClipLab checks on
this candidate. The earlier forced-clone `ENOSYS` preflight remains
recorded as setup failure, not an assertion result.
- The unchanged default `pnpm test:run` started at 01:51:33.796 UTC on
clean exact `82d13d4`. All six real chat partitions passed: 358, 314,
273, 239, 311 and 321 cases, or 1,816 actual cases. The original FIFO
and ambiguous-retry cases both passed under their unchanged 15-second
limits. General-server then reported 37 completed file rows with 5,438
passed cases and five default remote-continuity skips, including all 498
tool-access service cases and all 83 connection-route cases. At 02:39:44
UTC, the unchanged 5-GiB host-disk guard terminated the owned run. The
child ended with SIGTERM and the container/harness exited 143, with no
OOM or recorded assertion failure. Only seven of 28 RUN invocations
started. Retention had no completed local row. Later invocations did not
run. This whole local command is interrupted/incomplete, not passed.
- A coverage check found no default suite missing from current full PR
CI. The maintained launcher tests prove exact coverage for all 12 PR
server shards, chat and five native suites; all configured workspace
projects; and all nine serialized shards. The final Runner CI shard
executes the five native server suites. The 41 local launcher/workflow
cases cover six sequential chat children, per-child isolation, native
retention, fail-fast propagation and unchanged explicit
selection/shared-database paths. The actual six-chat run above exercises
that changed default path. The remaining unqualified local difference is
uninterrupted sequential whole-command composition and resource
accumulation, not omitted test selection. Current exact-tree full CI
supplies broad source-test qualification; no local whole-pass claim or
provider acceptance is inferred.
- A task-owned remote runner was considered to complete that separate
composition check. Its provider rejected the requested allocation with
per-sandbox limits of 8 GiB RAM and 10 GiB disk. No sandbox was admitted
and no remote tests ran. No tests, timeouts, assertions, database
synchronization, source or provider settings were changed to avoid the
capacity limit. Historical a0 failures remain below. No deployment or
live repaired-draft acceptance has run.

### Bounded follow-up on the prior source

- On a0, one finite phase-traced chat diagnostic executed exactly the
two failed cases. Both passed (1,374 and 1,767 ms); 1,814 other cases
were filtered, not passed. This did not reproduce a blocked await or
establish the original monolithic timeout cause. No assertion or
15-second limit changed.
- The retention diagnosis preserved an initial zero-body selector
failure, then corrected only the selector: one original case passed in
783 ms, with 42 filtered. Trace showed the old settling helper advancing
logical time while real I/O was active. A new held-I/O regression then
failed hard because the old helper advanced 1,000 ms. This proves a
test-driver defect, not the precise cause of the old 5,340 ms full-run
gap.
- The first manual-clock repair failed the complete 44-case file: 43
passed, one body hit 15 seconds and cleanup hit 30 seconds. Its planned
mutant did not run. Source then identified a fresh PostgreSQL
connection's zero-duration timer. The corrected driver pumps
zero-duration timers while initial readiness or tracked I/O is pending,
without advancing logical Date. That pre-integration file passed 44/44
with no skips. A separate scratch-only 5,000 ms normal-continuation
mutant failed the original hard `5000 < 3000` assertion; its expected
failure is not a test pass. The original page-count, fairness, expiry
and quiet assertions stayed intact. The authored repair does not change
the production worker. The merged master changes an older retention case
and dependencies; these pre-integration results are not a complete 82
retention or whole-suite pass.

### Historical qualification: SHA-bound, not 82 acceptance

- Historical a0 candidate: `a0f9089fc1ff466bf4e1eeddc5b1a910d09e380b`,
tree `b9d73713319dcd52b9eaa6053008b5190e43fdf1`. It adds the
overlapping-start repair to parent `6c18`, which already merged pinned
master `1511c92965325249ed6b52dd5be52eb6d736fbfa`. That candidate
changed 12 files. Its CI and review passed; its local full qualification
remained failed/incomplete.
- Greptile's previous `6c18` review found P1 [Latest service choice gets
lost](https://github.com/paperclipai/paperclip/pull/18048#discussion_r4425965828),
confidence 4/5. Two hard-assert regressions reproduced it on untouched
`6c18`: the already-bound path overwrote the later choice, and real
first-use client stamping also restored the old choice. Existing OAuth
states/grants were not widened, but a later setup could restore an older
request. The new repair covers both write boundaries without holding
locks over external work or copying another attempt's scopes.
- The complete affected service file passed 498 tests with no failures
or skips. All 31 required controls ran, including both unchanged RED
cases, 14 new client-drift cases, seven earlier identity guards,
reconnect/cold-resume cases and upstream transaction tests. The tested
three-file patch is byte-identical to this commit; the connector
contract update is separate. Independent scoped review found no
additional actionable issue. Fresh Greptile reviewed exact `a0f` at 5/5
with no actionable findings. The previous P1 thread is resolved.
- On exact `a0f`, the five shared/UI files passed 312 tests. An initial
complete connection-management run then failed: 74 passed, eight timed
out at the unchanged 15-second limit, and one AI-account verification
returned 422 instead of 201. The Google addressed-user handoff passed.
The container exited 1 without OOM and stopped before chat/token/whole
tests. This material failure remains retained and its cause
unclassified; the later controlled recovery below is not a causal
explanation.
- One diagnostic executed the first failed gateway case and the two AI
cases with temporary phase-only tracing. All three passed at 8.516,
5.511 and 3.523 seconds; 80 other cases were filtered. Original hard
assertions and timeouts stayed unchanged. No timeout or suspected late
provider cleanup reproduced, so the complete-file failure remains
unresolved. Vitest exited 0; the harness exited 1 because its trace
parser missed ANSI prefixes. All 203 events were recovered from retained
logs without rerunning tests. Instrumentation was removed. This is not
acceptance.
- Historical a0 CI
[41097596364](https://github.com/paperclipai/paperclip/actions/runs/41097596364)
completed successfully: 52 successful checks, two expected Storybook
skips and no failed/pending checks. Legacy Snyk passed. All 48 CI jobs
passed, including all 1,816 chat cases and explicit passing lines for
the original 20 local failures. [Server
11/12](https://github.com/paperclipai/paperclip/actions/runs/41097596364/job/124207469276)
explicitly passed the complete 83-case connection file in 129.49
seconds, including all nine local failure titles. The actual checkout
was synthetic merge `06851da6eb969d63e1c7dc9f7c95f08d09fa4ff1`,
combining `a0f` with `aa58fa107389e8b9ae5949f7ad815943dfd94292`. This is
not standalone local `a0f` qualification and does not erase the nine
local route failures.
- Linux admission uses Node 24.22.2, pnpm 9.15.4 and Rust 1.97.1. Frozen
offline install, exact native hashes and real migrated PostgreSQL 18.1
passed. Native/protocol/build/lock inputs are identical between `6c18`
and `a0f`; validated cache reuse retains the actual `6c18` native-build
provenance. The selected packaged release binary is not called a fresh
`a0f` build. Database synchronization settings stayed on. This admission
alone does not explain the route failures.
- A fresh Mac snapshot of exact `a0f` passed frozen install, workspace
prerequisites, full sequential recursive typecheck and normal `pnpm
build`, both on their first attempt. Typecheck completed at 17:43:05
UTC, build at 17:44:43 UTC; both exited 0 with clean tracked/untracked
source. Pinned Node 24.19, pnpm 9.15.4, Rust 1.97.1, package concurrency
one, Cargo jobs one and a 12 GiB V8 heap ceiling were retained.
Largest-process maximum RSS was 7,668,154,368 bytes for typecheck and
10,038,837,248 bytes for build, not aggregate tree peaks. The first
build correctly withheld its freshness stamp because 125 ignored, pinned
Copilot assets appeared during preparation; exact path/hash analysis
explained this. The normal build then recorded a genuine stamp and
reused Cargo outputs in 1.18 seconds. No manual stamp, source workaround
or gate retry. This is darwin/arm64 static/build proof, not Linux
packaging/runtime or full tests.
- The historical a0 candidate's token checker passed all four gates
across 1,410 files, run directly with pinned Node 24 against the script
behind `pnpm check:token-gates`.
- The single controlled complete-file comparison passed: baseline `6c18`
83/83 in 287.07 seconds, then candidate `a0f` 83/83 in 293.03 seconds,
with zero skips. All nine earlier failure titles passed on both. Both
child/container exits were 0, no OOM/interruption; source and
native/resolver/PostgreSQL guards matched. This ran only after all owned
Mac compilation ended, with fresh processes/databases and identical
four-CPU-quota/6-GiB/overlay inputs. No filtering, instrumentation,
assertion/timeout/source changes or retry loop. This establishes
standalone complete-file recovery, not the cause of the earlier eight
timeouts/AI 422.
- The first unchanged full `pnpm test:run` on exact clean `a0f` ran from
October 30 at 18:01:43 UTC to 21:06:19 UTC. It failed naturally with
test and container exit 1, without OOM. General-server reported 1,257
passed, one failed and seven skipped files; 28,633 passed, one failed
and 103 skipped tests. Only the first of 22 invocations ran. The later
four groups and 21 invocations did not run. The sole failure was the
unchanged chat-shard CLI meta-test: its nested projectless pnpm lookup
hit the offline network guard. This was a failed full gate, not Google
provider acceptance.
- A finite environment pair reproduced that one meta-test failure, then
passed the same unchanged case after supported `corepack prepare
pnpm@9.15.4 --activate` as the existing non-root node user. The cached
pnpm payload was identical. Only the missing node-user last-known-good
record changed. Core source, dependencies, lockfile, fixtures,
assertions, timers and offline guards stayed unchanged. This isolates
the nested collection defect; it does not explain the earlier route
failures or complete the full gate.
- The corrected unchanged full command ran from October 30 at
21:29:03.500 UTC to October 31 at 00:02:44.207 UTC on the same exact
clean `a0f`. It failed naturally with exit 1, signal null and no OOM.
Source stayed clean before, at start and after. It retained two visible
CPUs/quota, 6 GiB, private IPC, normal overlay, matching native inputs,
synchronized real PostgreSQL 18.1, and the default five groups/22
invocations. Only the first general-server invocation ran; the other 21
did not run. General-server reported 1,265 files: 1,256 passed, two
failed and seven skipped. It reported 28,737 tests: 28,631 passed, three
failed and 103 skipped. The two chat cases emitted `Test timed out in
15000ms` at `chat-channels.integration.test.ts:77310` and `:81790`, with
durations of 15,045 and 15,058 ms. These were the durable pre-run FIFO
notice and ambiguous GitHub text restart/retry cases. The retention case
emitted `expected 5340 to be less than 3000` at
`skill-version-retention.test.ts:593`. Its real duration was 1,727 ms;
5,340 ms was the recorded fake-time directory-page gap, not a deadline
timeout. The timeout stacks do not identify the blocked internal await.
The cause of these failures remains unclassified. Inside this same
failed run, the complete 83-case connection file and 498-case service
file passed. The former Corepack meta-case also passed in 6,396 ms.
These component passes do not waive the failed/incomplete full gate. No
rerun, cancellation, timeout/assertion change or full-pass claim. A
later fresh two-case phase trace passed both chat cases; it did not
reproduce or establish the original timeout cause. At that checkpoint,
the PR stayed draft.
- Prior `6c18` targeted checks passed 930 tests across eight files and
token gates. Its selected queue group passed all 53 cases, including the
original 20 failures and 12 new named controls. Backlog cases took 10.49
and 8.45 seconds under the unchanged 15-second limit. Another 1,763 chat
cases were intentionally filtered. This is historical targeted recovery,
not a full pass or proof of the new repair. No timeout or assertion was
relaxed.
- Prior `6c18` full local qualification failed during serial typecheck:
server TypeScript was killed with code 137. Docker confirmed OOM in the
6 GiB container; full tests and build never started. The failure is
retained. The new Mac resource plan avoids repeating that undersized run
and does not change the shared Docker VM.
- Prior CI
[41089793558](https://github.com/paperclipai/paperclip/actions/runs/41089793558)
completed successfully for synthetic merge
`a73bffcfa3ede051961bcabcfa576c703d10c932` (`6c18` plus
`abc270ee4c03e13b4d6eaad156a41b4ebed4bb0a`). All 48 CI jobs passed; all
1,816 chat cases passed, including explicit log lines for the original
20. Check runs were 51 success, two expected skips and one Greptile
failure; legacy Snyk passed. These results do not qualify `a0f` or erase
its local route failures.
- Red proof on the unmodified master baseline: cold picker and Finish
setup restore broad defaults; managed and customer-owned drafts lack
saved intent. The stale setup tests also fail before the lock guard.
- Live staging baseline `b25f3e2af8e77d882cd22d36ca55640e6468e46f` also
reproduces the ordinary failure: select Gmail read-only only, cancel at
Google, reload the connector catalog, then Finish setup. All three
groups return enabled at read/write. Authorization was declined; no new
Google access was granted. This is baseline failure evidence, not a
deployed-fix pass.
- Historical affected suites: 800 tests passed before the final lock
guard. The final focused service and handoff cases passed 126 tests
after that guard. These are not results for the integrated candidate.
- Historical `pnpm -r typecheck` and final lock-guard server typecheck
passed. The historical a0 candidate passed the full recursive gate
separately, as recorded above.
- `pnpm build`: passed again on the final lock-guard source in commit
`1c31cde0ed199fbf4e41b7649590554f7886dfc2`.
- Historical `pnpm check:token-gates` passed; the historical a0
candidate's direct checker pass is recorded above.
- `pnpm test:run` on macOS failed on the frozen commit. It exited 1
after 9,432.41 seconds. General server results: 1,195 files passed, 10
failed, eight skipped; 26,706 tests passed, one timed out, 157 skipped.
Nine setup hooks timed out while starting embedded PostgreSQL. The
retained log records host-wide semaphore exhaustion (`semget`), not disk
exhaustion. One unchanged publication-text performance case also timed
out. Its cause is not established. Later workspace and serialized groups
did not run. This is a failed and incomplete local gate.
- The independent full-source Linux run also failed and was incomplete
on the same frozen commit. Native and real-database admission passed.
Chat integration reported 20 failures in 1,593 tests. Tool-access passed
all 479 tests. Native-session-executor passed all 965 tests. Docker
interrupted the run after about 48 minutes on October 29. The container
exited 143 and was not OOM-killed. The run produced no final group
totals or detailed assertion summary. Shell trap metadata reports zero,
but this is not a valid test-pass result. Source and evidence remain
unchanged. The focused diagnosis below retains those failures; their
cause is not assumed from the interruption. Neither historical local
full run passed.
- A fresh focused replay on the frozen source then executed exactly
those 20 case bodies. All 20 failed; this was not a setup-hook failure.
The two backlog cases reached the unchanged 15-second timeout. The 18
FIFO cases failed on missing notices or receipt rows. All 18 FIFO cases
passed when run alone in a fresh process and database. This supports an
order interaction, not a complete cause or baseline conclusion. A
separate two-backlog control used RAM-backed temporary storage and real
PostgreSQL with synchronization settings on. One backlog case passed in
10.65 seconds; the other still timed out at 15 seconds. No source,
assertion or timeout was changed. The full local gate remains open.
- Earlier mixed-revision run: it began before the final guard and
imported older source before collecting later regression cases. It
failed those new cases with that older cached module. This run is
retained, not counted as candidate acceptance. The fresh focused run
passed all seven stale-setup cases. No case was skipped to hide a
failure.
- Historical remote gates for `1c31` were terminal and clean: 53
successful contexts and two expected Storybook skips. Main CI
[40720536514](https://github.com/paperclipai/paperclip/actions/runs/40720536514)
passed. Greptile reviewed exact head
`1c31cde0ed199fbf4e41b7649590554f7886dfc2` at 5/5 with no actionable
findings; the separate review job also passed, and there were no inline
review comments. These results do not qualify `6c18`.
- CI source qualification: the chat jobs checked out synthetic merge
`ef36746b3d2ec52ba5727dcbe4da265a03421fe4`, which merges the PR into
`7d75ae0569e41786640cf09647ae8849411d2f22`. All 20 locally failing case
names have explicit passing CI log lines. These passes do not establish
standalone local acceptance of `1c31`. The merge adds two upstream Teams
tests, so it collects 1,595 chat cases instead of 1,593. Neither
backlog/FIFO assertions nor their timeouts changed.
- Independent source review found an earlier credential-identity race.
The guard and seven deterministic cases resolve that finding. A later
integration review missed the separate overlapping-start race. Both RED
failures and that review miss are retained. The narrow follow-up repair
passed 498 service cases, independent source review and fresh remote
CI/5-of-5 review; full local qualification remains incomplete.
- Source qualification is ready for merge. Live repaired-draft recovery,
delivery and Google approval are not established by these results. The
local whole command remains explicitly incomplete.

## Risks

- Historical drafts without a matching intent now need an explicit
service choice. This is deliberate and prevents silent broadening.
- A concurrent credential or method edit now returns 409. Reloading lets
the person review the changed setup.
- Overlapping starts keep separate requested scope sets. Updating draft
intent does not relabel an earlier OAuth state's consent request.
- Saved intent is not consent. Provider-reported scopes and current
action policies still control discovery and dispatch.
- No migration is needed. No existing token or grant is rewritten by
saving draft intent.

- Default ungrouped launch paths now execute six chat children in
sequence and 28 whole-command invocations. Exact coverage, local
launcher contracts and all six real default chat partitions passed. A
complete uninterrupted local whole-command run remains unqualified
because of host capacity.
- The retention test-clock repair depends on owned-I/O and zero-duration
connection-timer progress. The controlled regression, sensitivity mutant
and current exact-tree CI protect the original assertion. It did not
reach a completed row in the interrupted local whole run.

## Model Used

OpenAI Codex, described by the runtime as GPT-5 based. The exact runtime
model ID and context-window size are not exposed to this task. Used
reasoning, code execution, browser verification, and independent
delegated review.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used, including the runtime metadata
limit
- [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 described the issue in-PR following the bug report template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket or instance identifier
- [x] I have run tests locally and they pass — exact clean 82 passed 41
launcher/workflow cases and all 1,816 real default chat cases, full
recursive typecheck, normal build and token gates. Full exact-tree PR CI
passed. The separate local whole `pnpm test:run` was interrupted by disk
protection and remains incomplete; it is not claimed as a local whole
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 risks above
- [x] All Paperclip CI gates are green — current exact-head checks
passed; the verified CI checkout tree equals clean 82. The separate
interrupted local whole command is disclosed above.
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
fresh exact-82 summary and check passed; the earlier finding is
resolved. This is source review, not acceptance.
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-30 22:02:58 -05:00

4673 lines
213 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { aiConnectionsApi } from "@/api/ai-connections";
import { queryKeys } from "@/lib/queryKeys";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return";
import { AppsConnect } from "./AppsConnect";
import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
getExperimental: experimentalMock,
get: async () => ({ defaultEnvironmentId: "local-env" }),
getGeneral: async () => ({}),
} }));
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listConnectionGrantsMock = 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),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
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("opens the saved AI sign-in and reuses a completed account", async () => {
mockSearch.value = "aiLogin=saved-login";
const setup = vi.spyOn(aiConnectionsApi, "loginSetup").mockResolvedValue({
sessionId: "saved-login", environmentId: "local-env", status: "completed", connectionId: "saved-account", grantId: "saved-grant",
intent: { provider: "openai", method: "subscription", name: "Saved account", ownership: "personal", agentIds: ["agent-1"], allAgents: false },
});
await render();
expect(setup).toHaveBeenCalledWith("company-1", "saved-login");
expect(container.textContent).toContain("This account is connected.");
expect(connectAppMock).not.toHaveBeenCalled();
await act(async () => buttonByText("View connection")?.click());
expect(mockNavigate).toHaveBeenCalledWith("/apps/saved-account/permissions");
});
it("shows an unavailable saved sign-in without creating a replacement", async () => {
mockSearch.value = "aiLogin=another-users-login";
vi.spyOn(aiConnectionsApi, "loginSetup").mockRejectedValue(new Error("Sign-in attempt not found"));
await render();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Sign-in attempt not found");
expect(connectAppMock).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("keeps Gmail read-only selected when Finish setup resumes a declined Workspace draft", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
const workspace = getAppStoreDefinition("google-workspace")!;
mockSearch.value = `source=google-workspace&resume=${connectionId}&oauth=denied`;
listGalleryMock.mockResolvedValue({ apps: [{ ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true } }] });
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-workspace", companyId: "company-1", status: "draft", metadata: { sourceTemplateKey: "google-workspace" } }] });
listConnectionsMock.mockResolvedValue({ connections: [{
...notionConnection(connectionId, "per_user", "draft"), companyId: "company-1", applicationId: "app-workspace",
config: { sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace", workspaceSetup: {
version: 1, companyId: "company-1", connectionId, actorType: "user", actorId: "me", grantKind: "user", subjectId: "me",
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
} },
}] });
listConnectionGrantsMock.mockResolvedValue({ currentUserId: "me", grants: [] });
await render();
await vi.waitFor(() => expect(container.textContent).toContain("Try again"));
await act(async () => buttonByText("Try again")!.click());
await flushReact();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"] select')).not.toBeNull());
const dialog = document.querySelector('[role="dialog"]')!;
expect([...dialog.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]);
expect(dialog.querySelector("select")?.value).toBe("read");
await act(async () => buttonByText("Continue")!.click());
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true, workspaceServices: { gmail: "read" } });
});
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);
}
});
});