mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
## 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>
21033 lines
768 KiB
TypeScript
21033 lines
768 KiB
TypeScript
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import * as fs from "node:fs/promises";
|
|
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
import {
|
|
applyPendingMigrations,
|
|
activityLog,
|
|
agents,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
companySecretBindings,
|
|
connectionGrantMembers,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
connectionTokenIssuances,
|
|
companySecrets,
|
|
companySecretVersions,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueThreadInteractions,
|
|
issues,
|
|
principalPermissionGrants,
|
|
secretAccessEvents,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCallEvents,
|
|
toolCatalogEntries,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
toolOauthStates,
|
|
toolInvocations,
|
|
toolMcpGateways,
|
|
toolPolicies,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolRuntimeMetricCounters,
|
|
toolRuntimeSlots,
|
|
toolStdioCommandTemplates,
|
|
userSecretDefinitions,
|
|
userSecretDeclarations,
|
|
} from "@paperclipai/db";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
APP_DEFINITIONS,
|
|
APP_STORE_HIDDEN_SLUGS,
|
|
GITHUB_CONNECTOR_PROFILES,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
googleWorkspaceSelectedScopes,
|
|
GOOGLE_WORKSPACE_DEFAULT_SELECTION,
|
|
getAvailableConnectionMethod,
|
|
getConnectableAppDefinition,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
classifyRisk,
|
|
normalizeConnectionMethodConfig,
|
|
projectedConnectionHeaders,
|
|
projectConnectionMethodToolInputSchema,
|
|
projectConnectionMethodToolArguments,
|
|
toolAccessService,
|
|
} from "../services/tool-access.js";
|
|
import { accessService } from "../services/access.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { createOAuthAuthorizationStart } from "../services/tool-access/oauth/authorization-start.js";
|
|
import { createOAuthClientRegistration } from "../services/tool-access/oauth/client-registration.js";
|
|
import { createPersonalConnectionIdentity } from "../services/tool-access/connections/personal-identity.js";
|
|
import { createConnectionSetupPolicy } from "../services/tool-access/connections/setup-policy.js";
|
|
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import {
|
|
canonicalToolArguments,
|
|
signToolArguments,
|
|
} from "../services/tool-content-guards.js";
|
|
import {
|
|
createToolGatewayService as createToolGatewayServiceBase,
|
|
type ToolGatewayService,
|
|
} from "../services/tool-gateway.js";
|
|
import { toolAccessRoutes } from "../routes/tool-access.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import * as sentry from "../sentry.js";
|
|
import { HttpError } from "../errors.js";
|
|
import type { VercelConnectClient } from "../services/vercel-connect.js";
|
|
import { invalidatePaperclipCloudConnectorCapabilities, PaperclipCloudConnectorError, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
|
|
|
|
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL");
|
|
const embeddedPostgresSupport = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
|
? describe
|
|
: describe.skip;
|
|
|
|
/**
|
|
* This suite predates the DNS-pinned HTTP transport and deliberately models
|
|
* remote servers with global fetch fixtures. Keep those protocol fixtures
|
|
* deterministic while the dedicated rebinding suite exercises real pinning.
|
|
*/
|
|
function createTestToolAccessService(
|
|
db: ReturnType<typeof createDb>,
|
|
options: Parameters<typeof toolAccessService>[1] = {},
|
|
) {
|
|
return toolAccessService(db, {
|
|
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
|
remoteHttpRequest: async (url, init) => fetch(url, init),
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function optIntoAirtableDcrRegistration() {
|
|
const method = APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods
|
|
.find((entry) => entry.key === "mcp-oauth")!;
|
|
const original = method.oauthClientRegistration;
|
|
method.oauthClientRegistration = "dcr";
|
|
return () => {
|
|
if (original === undefined) delete method.oauthClientRegistration;
|
|
else method.oauthClientRegistration = original;
|
|
};
|
|
}
|
|
|
|
function fakeGoogleWorkspaceConnector(
|
|
companyId: string,
|
|
userId: string,
|
|
profile: GoogleWorkspaceConnectorProfileId = "gmail.draft",
|
|
): PaperclipCloudConnector {
|
|
const profileDefinition = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile];
|
|
const tokenPrefix = profile.split(".")[0]!;
|
|
const credentials = {
|
|
v: 1 as const,
|
|
accessToken: `${tokenPrefix}-access-token`,
|
|
refreshToken: `${tokenPrefix}-refresh-token`,
|
|
tokenType: "Bearer",
|
|
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
scopes: [...profileDefinition.scopes],
|
|
subject: userId,
|
|
companyId,
|
|
instanceId: "test-instance",
|
|
environment: "development" as const,
|
|
provider: "google" as const,
|
|
profile,
|
|
};
|
|
return {
|
|
getCapabilities: vi.fn(async () => [profile]),
|
|
startAuthorization: vi.fn(async ({ returnState }) => ({
|
|
authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(returnState)}`,
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
})),
|
|
claim: vi.fn(async () => credentials),
|
|
refresh: vi.fn(async () => credentials),
|
|
revoke: vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
function fakeGmailConnector(
|
|
companyId: string,
|
|
userId: string,
|
|
): PaperclipCloudConnector {
|
|
return fakeGoogleWorkspaceConnector(companyId, userId);
|
|
}
|
|
|
|
function fakeGitHubConnector(
|
|
companyId: string,
|
|
subject: string,
|
|
): PaperclipCloudConnector {
|
|
const credentials = {
|
|
v: 1 as const,
|
|
accessToken: "ghu_non_expiring_access_token",
|
|
refreshToken: null,
|
|
tokenType: "Bearer",
|
|
accessTokenExpiresAt: null,
|
|
refreshTokenExpiresAt: null,
|
|
scopes: [...GITHUB_CONNECTOR_PROFILES["github.code"].scopes],
|
|
subject,
|
|
companyId,
|
|
instanceId: "test-instance",
|
|
environment: "development" as const,
|
|
provider: "github" as const,
|
|
profile: "github.code" as const,
|
|
appSlug: "paperclip-development",
|
|
};
|
|
return {
|
|
getCapabilities: vi.fn(async () => ["github.code" as const]),
|
|
startAuthorization: vi.fn(async ({ returnState }) => ({
|
|
authorizationUrl: `https://github.com/login/oauth/authorize?state=${encodeURIComponent(returnState)}`,
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
})),
|
|
claim: vi.fn(async () => credentials),
|
|
refresh: vi.fn(async () => credentials),
|
|
revoke: vi.fn(async () => undefined),
|
|
setWebhookBinding: vi.fn(async () => undefined),
|
|
leaseEvents: vi.fn(async () => null),
|
|
acknowledgeEvents: vi.fn(async () => 0),
|
|
};
|
|
}
|
|
|
|
function createToolGatewayService(
|
|
db: ReturnType<typeof createDb>,
|
|
options: NonNullable<Parameters<typeof createToolGatewayServiceBase>[1]> = {},
|
|
) {
|
|
return createToolGatewayServiceBase(db, {
|
|
remoteHttpRequest: async (url, init) => fetch(url, init),
|
|
...options,
|
|
});
|
|
}
|
|
|
|
async function createCompany(db: ReturnType<typeof createDb>) {
|
|
return db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Tool Access CRUD ${randomUUID()}`,
|
|
issuePrefix: `TC${randomUUID().slice(0, 6).toUpperCase()}`,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createComposioParentAndChild(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
) {
|
|
const secrets = secretService(db);
|
|
const apiKey = await secrets.create(companyId, {
|
|
name: `Composio test key ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.credentials_apiKey`,
|
|
provider: "local_encrypted",
|
|
value: "composio-test-key",
|
|
});
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
name: "Composio",
|
|
type: "rest_api",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [parent] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: "Composio",
|
|
uid: `composio/${randomUUID()}`,
|
|
transport: "rest_api",
|
|
authKind: "api_key",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { sourceTemplateKey: "composio" },
|
|
transportConfig: { sourceTemplateKey: "composio" },
|
|
credentialRefs: [
|
|
{
|
|
name: "credentials.apiKey",
|
|
secretId: apiKey.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "x-api-key",
|
|
prefix: null,
|
|
},
|
|
],
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: apiKey.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.apiKey",
|
|
required: true,
|
|
label: "Composio API key",
|
|
},
|
|
],
|
|
})
|
|
.returning();
|
|
await db.insert(companySecretBindings).values({
|
|
companyId,
|
|
secretId: apiKey.id,
|
|
targetType: "tool_connection",
|
|
targetId: parent!.id,
|
|
configPath: "credentials.apiKey",
|
|
});
|
|
const [child] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: "GitHub (via Composio)",
|
|
uid: `composio/github/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
authKind: "none",
|
|
status: "active",
|
|
enabled: true,
|
|
config: {
|
|
provider: "composio",
|
|
parentConnectionId: parent!.id,
|
|
toolkitSlug: "github",
|
|
connectedAccountId: "account-github",
|
|
},
|
|
transportConfig: {},
|
|
})
|
|
.returning();
|
|
return { parent: parent!, child: child! };
|
|
}
|
|
|
|
// Build a Response-like object that mirrors what `fetch` returns for an MCP
|
|
// Streamable HTTP JSON response: `text()`, `json()`, and a `content-type`
|
|
// header. Production now reads the body via `text()` + content-type so it can
|
|
// also decode SSE-framed responses, so test doubles must supply both.
|
|
function mcpHttpResponse(
|
|
payload: unknown,
|
|
opts: { contentType?: string; body?: string } = {},
|
|
): Response {
|
|
const contentType = opts.contentType ?? "application/json";
|
|
const body = opts.body ?? JSON.stringify(payload);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "content-type" ? contentType : null,
|
|
},
|
|
text: async () => body,
|
|
json: async () => payload,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
// Build an SSE-framed (`event: message\ndata: {…}`) MCP Streamable HTTP
|
|
// response, the shape a spec-compliant server returns once the request carries
|
|
// the `Accept: application/json, text/event-stream` header.
|
|
function mcpSseResponse(payload: unknown): Response {
|
|
return mcpHttpResponse(payload, {
|
|
contentType: "text/event-stream",
|
|
body: `event: message\ndata: ${JSON.stringify(payload)}\n\n`,
|
|
});
|
|
}
|
|
|
|
function mockToolsList(tools: unknown[]) {
|
|
return vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return mcpHttpResponse({ jsonrpc: "2.0", id: body.id,
|
|
result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools } });
|
|
});
|
|
}
|
|
|
|
const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp";
|
|
|
|
async function withGalleryServerUrl<T>(
|
|
slug: string,
|
|
serverUrl: string,
|
|
operation: () => Promise<T>,
|
|
methodKey?: string,
|
|
): Promise<T> {
|
|
const definition = getConnectableAppDefinition(slug);
|
|
const methods = definition?.methods ?? [];
|
|
// Legacy GitHub fixture callers model token auth, not managed OAuth.
|
|
const fixtureMethodKey = methodKey ?? (slug === "github" ? "mcp-key" : undefined);
|
|
const method = fixtureMethodKey
|
|
? methods.find((candidate) => candidate.key === fixtureMethodKey)
|
|
: definition
|
|
? getAvailableConnectionMethod(definition, null)
|
|
: undefined;
|
|
if (!method?.defaults)
|
|
throw new Error(`Missing gallery method defaults for ${slug}`);
|
|
const originalServerUrl = method.defaults.serverUrl;
|
|
method.defaults.serverUrl = serverUrl;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
method.defaults.serverUrl = originalServerUrl;
|
|
}
|
|
}
|
|
|
|
function createRouteApp(
|
|
db: ReturnType<typeof createDb>,
|
|
actor?: Express.Request["actor"],
|
|
toolGateway?: ToolGatewayService,
|
|
deployment?: {
|
|
deploymentMode?: "local_trusted" | "authenticated";
|
|
deploymentExposure?: "private" | "public";
|
|
paperclipCloudConnector?: PaperclipCloudConnector | null;
|
|
},
|
|
useProtocolFixtureTransport = true,
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(async (req, _res, next) => {
|
|
// Session fixtures must persist the membership normally loaded by authentication.
|
|
// Keep existing rows unchanged so explicit restrictions and revocations survive.
|
|
if (actor?.type === "board" && actor.userId) {
|
|
for (const membership of actor.memberships ?? []) await db.insert(companyMemberships).values({
|
|
companyId: membership.companyId, principalType: "user", principalId: actor.userId,
|
|
membershipRole: membership.membershipRole, status: membership.status,
|
|
}).onConflictDoNothing();
|
|
}
|
|
req.actor = actor ?? {
|
|
type: "board",
|
|
userId: "board-user",
|
|
userName: "Board User",
|
|
userEmail: null,
|
|
isInstanceAdmin: true,
|
|
source: "local_implicit",
|
|
};
|
|
next();
|
|
});
|
|
app.use(
|
|
"/api",
|
|
toolAccessRoutes(db, {
|
|
toolGateway,
|
|
...(useProtocolFixtureTransport
|
|
? {
|
|
remoteHttpEndpointLookup: async () => [
|
|
{ address: "8.8.8.8", family: 4 as const },
|
|
],
|
|
remoteHttpRequest: async (url: string, init: RequestInit) =>
|
|
fetch(url, init),
|
|
}
|
|
: {}),
|
|
...deployment,
|
|
}),
|
|
);
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function boardSessionActor(
|
|
companyId: string,
|
|
membershipRole: "owner" | "admin" | "operator" | "member" | "viewer",
|
|
userId = `${membershipRole}-${randomUUID()}`,
|
|
sessionId = `session-${randomUUID()}`,
|
|
): Express.Request["actor"] {
|
|
return {
|
|
type: "board",
|
|
userId,
|
|
sessionId,
|
|
userName: `${membershipRole} user`,
|
|
userEmail: null,
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
companyIds: [companyId],
|
|
memberships: [{ companyId, membershipRole, status: "active" }],
|
|
};
|
|
}
|
|
|
|
async function grantBoardUser(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
userId: string,
|
|
permissionKeys: string[],
|
|
membershipRole:
|
|
"owner" | "admin" | "operator" | "member" | "viewer" = "operator",
|
|
) {
|
|
await db.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole,
|
|
});
|
|
if (permissionKeys.length > 0) {
|
|
await db.insert(principalPermissionGrants).values(
|
|
permissionKeys.map((permissionKey) => ({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey,
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createAgent(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
status = "active",
|
|
) {
|
|
return db
|
|
.insert(agents)
|
|
.values({
|
|
companyId,
|
|
name: `Test Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
status,
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
}
|
|
|
|
async function createIssueAndRun(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
agentId: string,
|
|
) {
|
|
await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: "user-for-run",
|
|
status: "active",
|
|
membershipRole: "member",
|
|
})
|
|
.onConflictDoNothing();
|
|
const [issue] = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId,
|
|
title: `Broker issue ${randomUUID()}`,
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
})
|
|
.returning();
|
|
const [run] = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
contextSnapshot: {
|
|
issueId: issue!.id,
|
|
responsibleUserId: "user-for-run",
|
|
},
|
|
})
|
|
.returning();
|
|
return { issue: issue!, run: run! };
|
|
}
|
|
|
|
function agentJwtActor(
|
|
companyId: string,
|
|
agentId: string,
|
|
runId: string,
|
|
): Express.Request["actor"] {
|
|
return {
|
|
type: "agent",
|
|
companyId,
|
|
agentId,
|
|
runId,
|
|
source: "agent_jwt",
|
|
};
|
|
}
|
|
|
|
async function allowConnectionForAgent(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
agentId: string,
|
|
connectionId: string,
|
|
input: { brokerMint?: boolean } = {},
|
|
) {
|
|
await db.insert(toolConnectionInstalls).values({
|
|
companyId,
|
|
connectionId,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
const [profile] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: `broker-${randomUUID()}`,
|
|
name: `Broker profile ${randomUUID()}`,
|
|
defaultAction: "deny",
|
|
})
|
|
.returning();
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId,
|
|
profileId: profile!.id,
|
|
targetType: "agent",
|
|
targetId: agentId,
|
|
});
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId,
|
|
profileId: profile!.id,
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId,
|
|
});
|
|
if (input.brokerMint ?? true) {
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId,
|
|
profileId: profile!.id,
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "connection_token.mint",
|
|
});
|
|
}
|
|
return profile!;
|
|
}
|
|
|
|
async function createBrokerConnection(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
input: {
|
|
path?: "exchange" | "static";
|
|
parentScopes?: string[];
|
|
defaultScopes?: string[];
|
|
rateLimitPerHour?: number;
|
|
healthStatus?:
|
|
| "unknown"
|
|
| "healthy"
|
|
| "degraded"
|
|
| "failed"
|
|
| "unchecked"
|
|
| "ok"
|
|
| "error"
|
|
| "missing_secret";
|
|
tokenUrl?: string;
|
|
protocol?: "pages" | "generic" | "rfc8693";
|
|
} = {},
|
|
) {
|
|
const secret = await secretService(db).create(companyId, {
|
|
provider: "local_encrypted",
|
|
name: `Broker parent ${randomUUID()}`,
|
|
key: `broker.parent.${randomUUID()}`,
|
|
value: "parent-deploy-token",
|
|
});
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: "paperclip-pages",
|
|
name: `Paperclip Pages ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: `Pages connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: input.healthStatus ?? "ok",
|
|
config: {
|
|
service: "pages",
|
|
namespaceAllowlist: ["dotta"],
|
|
tokenBroker: {
|
|
enabled: true,
|
|
path: input.path ?? "exchange",
|
|
tokenUrl:
|
|
input.tokenUrl ?? "https://93.184.216.34/v1/tokens/exchange",
|
|
...(input.protocol ? { protocol: input.protocol } : {}),
|
|
parentCredentialConfigPath: "credentials.deploy_token",
|
|
parentScopes: input.parentScopes ?? ["pages:publish:ns/dotta"],
|
|
defaultScopes: input.defaultScopes ?? [],
|
|
...(input.rateLimitPerHour !== undefined
|
|
? { rateLimitPerHour: input.rateLimitPerHour }
|
|
: {}),
|
|
},
|
|
},
|
|
transportConfig: {},
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.deploy_token",
|
|
required: true,
|
|
label: "Pages deploy token",
|
|
},
|
|
],
|
|
})
|
|
.returning();
|
|
await db.insert(companySecretBindings).values({
|
|
companyId,
|
|
secretId: secret.id,
|
|
targetType: "tool_connection",
|
|
targetId: connection!.id,
|
|
configPath: "credentials.deploy_token",
|
|
});
|
|
return { application: application!, connection: connection!, secret };
|
|
}
|
|
|
|
async function createOAuthConnection(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
input: { tokenBroker?: Record<string, unknown> } = {},
|
|
) {
|
|
const accessSecret = await secretService(db).create(companyId, {
|
|
provider: "local_encrypted",
|
|
name: `OAuth access ${randomUUID()}`,
|
|
key: `oauth.access.${randomUUID()}`,
|
|
value: "stored-upstream-oauth-access-token",
|
|
});
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: `oauth-fixture-${randomUUID()}`,
|
|
name: `OAuth fixture ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: `OAuth connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "ok",
|
|
config: {
|
|
url: "https://oauth-app.example.test/mcp",
|
|
oauth: {
|
|
provider: "slack",
|
|
tokenUrl: "https://oauth-app.example.test/oauth/token",
|
|
scopes: ["channels:write"],
|
|
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
},
|
|
...(input.tokenBroker ? { tokenBroker: input.tokenBroker } : {}),
|
|
},
|
|
transportConfig: { url: "https://oauth-app.example.test/mcp" },
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: accessSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "oauth.access_token",
|
|
required: true,
|
|
label: "OAuth access token",
|
|
},
|
|
],
|
|
})
|
|
.returning();
|
|
await db.insert(companySecretBindings).values({
|
|
companyId,
|
|
secretId: accessSecret.id,
|
|
targetType: "tool_connection",
|
|
targetId: connection!.id,
|
|
configPath: "oauth.access_token",
|
|
});
|
|
return { application: application!, connection: connection!, accessSecret };
|
|
}
|
|
|
|
async function createRemoteToolFixture(
|
|
db: ReturnType<typeof createDb>,
|
|
companyId: string,
|
|
input: {
|
|
riskLevel?: "read" | "write" | "destructive";
|
|
quarantined?: boolean;
|
|
} = {},
|
|
) {
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: `fixture-${randomUUID()}`,
|
|
name: `Fixture App ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
name: `Fixture Connection ${randomUUID()}`,
|
|
uid: `fixture/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example.test/mcp" },
|
|
transportConfig: { url: "https://fixture.example.test/mcp" },
|
|
healthStatus: "ok",
|
|
credentialPolicy: "shared",
|
|
})
|
|
.returning();
|
|
await db.insert(connectionGrants).values({
|
|
companyId,
|
|
connectionId: connection!.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: true,
|
|
});
|
|
const riskLevel = input.riskLevel ?? "write";
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
entryKind: "tool",
|
|
name: `send_email-${randomUUID()}`,
|
|
toolName: "send_email",
|
|
title: "Send email",
|
|
description: "Send a fixture email.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { to: { type: "string" }, body: { type: "string" } },
|
|
required: ["to"],
|
|
additionalProperties: true,
|
|
},
|
|
annotations: { readOnlyHint: riskLevel === "read" },
|
|
riskLevel,
|
|
isReadOnly: riskLevel === "read",
|
|
isWrite: riskLevel === "write",
|
|
isDestructive: riskLevel === "destructive",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
quarantinedAt: input.quarantined ? new Date() : null,
|
|
quarantineReason: input.quarantined ? "pending_review" : null,
|
|
})
|
|
.returning();
|
|
return {
|
|
application: application!,
|
|
connection: connection!,
|
|
catalogEntry: catalogEntry!,
|
|
};
|
|
}
|
|
|
|
type IdRef = { id: string };
|
|
|
|
function catalogResponse(tools: unknown[]) {
|
|
return { jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } };
|
|
}
|
|
|
|
function readTool(name: string, description: string) {
|
|
return {
|
|
name: name,
|
|
description: description,
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
};
|
|
}
|
|
|
|
function oauthToken(accessToken: string, refreshToken: string) {
|
|
return {
|
|
ok: true,
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
};
|
|
}
|
|
|
|
function memberActor(allowedCompany: IdRef, isInstanceAdmin: boolean): Express.Request["actor"] {
|
|
return {
|
|
type: "board",
|
|
userId: "member-user",
|
|
userName: "Member User",
|
|
userEmail: null,
|
|
companyIds: [allowedCompany.id],
|
|
memberships: [
|
|
{
|
|
companyId: allowedCompany.id,
|
|
membershipRole: "owner",
|
|
status: "active",
|
|
},
|
|
],
|
|
isInstanceAdmin: isInstanceAdmin,
|
|
source: "session",
|
|
};
|
|
}
|
|
|
|
function applicationRow(company: IdRef, name: string): typeof toolApplications.$inferInsert {
|
|
return {
|
|
companyId: company.id,
|
|
name: name,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
};
|
|
}
|
|
|
|
function personalGrantRow(company: IdRef, connection: IdRef): typeof connectionGrants.$inferInsert {
|
|
return {
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
status: "active",
|
|
isDefault: false,
|
|
};
|
|
}
|
|
|
|
function notionAuthorizationServer() {
|
|
return {
|
|
issuer: "https://mcp.notion.com",
|
|
authorization_endpoint: "https://mcp.notion.com/authorize",
|
|
token_endpoint: "https://mcp.notion.com/token",
|
|
registration_endpoint: "https://mcp.notion.com/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
};
|
|
}
|
|
|
|
function oauthCallbackInput(state: string): Parameters<ReturnType<typeof toolAccessService>["completeOAuthCallback"]>[0] {
|
|
return {
|
|
state,
|
|
code: "oauth-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("tool access service", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<
|
|
ReturnType<typeof startEmbeddedPostgresTestDatabase>
|
|
> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
if (externalDatabaseUrl) {
|
|
await applyPendingMigrations(externalDatabaseUrl);
|
|
db = createDb(externalDatabaseUrl);
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
|
|
return;
|
|
}
|
|
tempDb = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-tool-access-service-",
|
|
);
|
|
db = createDb(tempDb.connectionString);
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
await db.delete(toolOauthStates);
|
|
await db.delete(connectionTokenIssuances);
|
|
await db.delete(secretAccessEvents);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecrets);
|
|
await db.delete(activityLog);
|
|
await db.delete(toolCallEvents);
|
|
await db.delete(toolActionRequests);
|
|
await db.delete(toolInvocations);
|
|
await db.delete(toolAccessAuditEvents);
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(toolRuntimeMetricCounters);
|
|
await db.delete(toolRuntimeSlots);
|
|
await db.delete(toolStdioCommandTemplates);
|
|
await db.delete(toolConnectionInstalls);
|
|
await db.delete(toolMcpGateways);
|
|
await db.delete(toolProfileBindings);
|
|
await db.delete(toolProfileEntries);
|
|
await db.delete(toolProfiles);
|
|
await db.delete(toolCatalogEntries);
|
|
await db.delete(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
await db.delete(authUsers);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function waitForBlockedMembershipUpdate() {
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
const [waiting] = await db.execute<{ waiting: boolean }>(sql`
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM pg_stat_activity
|
|
WHERE state = 'active'
|
|
AND wait_event_type = 'Lock'
|
|
AND query ILIKE '%company_memberships%'
|
|
AND query ILIKE '%for update%'
|
|
) AS waiting
|
|
`);
|
|
if (waiting?.waiting) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
it.each(["pages", "rfc8693"] as const)("mints %s tokens over real HTTP and records upstream failures without token material", async (protocol) => {
|
|
vi.stubEnv("PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS", "127.0.0.1");
|
|
const received: Array<{ authorization: string | undefined; body: string }> = [];
|
|
let failExchange = false;
|
|
const server = createServer(async (incoming, response) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
|
|
received.push({ authorization: incoming.headers.authorization, body: Buffer.concat(chunks).toString() });
|
|
response.writeHead(failExchange ? 503 : 200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify(failExchange
|
|
? { error: "temporarily_unavailable" }
|
|
: { access_token: "real-http-child-token", expires_in: 600, scope: "pages:publish:ns/dotta" }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
try {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const port = (server.address() as { port: number }).port;
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
tokenUrl: `http://127.0.0.1:${port}/exchange`, protocol,
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const mint = () => request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
const result = await mint();
|
|
expect(result.status).toBe(200);
|
|
expect(result.body).toMatchObject({ status: "minted", token: "real-http-child-token", scope: ["pages:publish:ns/dotta"] });
|
|
expect(received).toHaveLength(1);
|
|
if (protocol === "pages") {
|
|
expect(received[0].authorization).toBe("Bearer parent-deploy-token");
|
|
expect(JSON.parse(received[0].body)).toMatchObject({ namespace: "dotta", actor: { id: agent.id, runId: run.id, onBehalfOf: "user:user-for-run" } });
|
|
} else {
|
|
const form = new URLSearchParams(received[0].body);
|
|
expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange");
|
|
expect(form.get("subject_token")).toBe("parent-deploy-token");
|
|
expect(form.get("scope")).toBe("pages:publish:ns/dotta");
|
|
}
|
|
failExchange = true;
|
|
const failed = await mint();
|
|
expect(failed.status).toBe(502);
|
|
expect(failed.body).toMatchObject({ code: "upstream_error" });
|
|
expect(received).toHaveLength(2);
|
|
const issuances = await db.select().from(connectionTokenIssuances)
|
|
.where(eq(connectionTokenIssuances.connectionId, connection.id));
|
|
expect(issuances).toHaveLength(2);
|
|
expect(issuances.find(row => row.outcome === "success")).toMatchObject({
|
|
tokenHash: createHash("sha256").update("real-http-child-token").digest("hex"),
|
|
companyId: company.id, agentId: agent.id, runId: run.id,
|
|
});
|
|
expect(issuances.find(row => row.outcome === "upstream_error")).toMatchObject({ tokenHash: null, errorCode: "upstream_error" });
|
|
const audits = await db.select().from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.connectionId, connection.id));
|
|
const persisted = JSON.stringify({ issuances, audits });
|
|
expect(persisted).not.toContain("parent-deploy-token");
|
|
expect(persisted).not.toContain("real-http-child-token");
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
expect(String(url)).toBe("https://93.184.216.34/v1/tokens/exchange");
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
authorization: "Bearer parent-deploy-token",
|
|
}),
|
|
);
|
|
const body = JSON.parse(String(init?.body));
|
|
expect(body).toMatchObject({
|
|
namespace: "dotta",
|
|
ttlSeconds: 900,
|
|
actions: ["publish"],
|
|
actor: {
|
|
type: "agent",
|
|
id: agent.id,
|
|
runId: run.id,
|
|
onBehalfOf: "user:user-for-run",
|
|
},
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: "child-pages-token",
|
|
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
|
scope: "pages:publish:ns/dotta",
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.set("X-Paperclip-Run-Id", run.id)
|
|
.send({ scope: "pages:publish:ns/dotta", requestedTtlSeconds: 5000 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: "minted",
|
|
connectionId: connection.id,
|
|
connection: { id: connection.id, uid: connection.uid },
|
|
grantId: expect.any(String),
|
|
path: "exchange",
|
|
token: "child-pages-token",
|
|
tokenType: "Bearer",
|
|
ttlSeconds: expect.any(Number),
|
|
scope: ["pages:publish:ns/dotta"],
|
|
attribution: {
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: expect.any(String),
|
|
responsibleUserId: "user-for-run",
|
|
},
|
|
});
|
|
expect(res.body.ttlSeconds).toBeLessThanOrEqual(900);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
const revoked = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.set("X-Paperclip-Run-Id", run.id)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
expect(revoked.status).toBe(403);
|
|
expect(revoked.body.error).toContain("no longer authorized");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
const issuances = await db.select().from(connectionTokenIssuances);
|
|
expect(issuances).toHaveLength(1);
|
|
expect(issuances[0]).toMatchObject({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
path: "exchange",
|
|
outcome: "success",
|
|
tokenHash: createHash("sha256").update("child-pages-token").digest("hex"),
|
|
});
|
|
expect(JSON.stringify(issuances)).not.toContain("child-pages-token");
|
|
expect(JSON.stringify(issuances)).not.toContain("parent-deploy-token");
|
|
|
|
const secretEvents = await db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(eq(secretAccessEvents.consumerId, connection.id));
|
|
expect(secretEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
configPath: "credentials.deploy_token",
|
|
heartbeatRunId: run.id,
|
|
outcome: "success",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("serializes exchange-token minting behind responsible-user membership revocation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const mintDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
const revocationDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const service = createTestToolAccessService(mintDb);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(
|
|
new Error("membership revocation must win before token exchange"),
|
|
);
|
|
let releaseRevocation!: () => void;
|
|
const revocationMayCommit = new Promise<void>((resolve) => {
|
|
releaseRevocation = resolve;
|
|
});
|
|
let membershipLocked!: () => void;
|
|
const membershipIsLocked = new Promise<void>((resolve) => {
|
|
membershipLocked = resolve;
|
|
});
|
|
let revocation: Promise<void> | null = null;
|
|
|
|
try {
|
|
revocation = revocationDb.transaction(async (tx) => {
|
|
await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
)
|
|
.for("update");
|
|
membershipLocked();
|
|
await revocationMayCommit;
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer", updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
});
|
|
|
|
await membershipIsLocked;
|
|
const mint = service
|
|
.mintConnectionTokenForAgent({
|
|
connectionId: connection.id,
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
body: { scope: "pages:publish:ns/dotta" },
|
|
})
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
);
|
|
|
|
expect(await waitForBlockedMembershipUpdate()).toBe(true);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
releaseRevocation();
|
|
await revocation;
|
|
|
|
const outcome = await mint;
|
|
expect(outcome.value).toBeNull();
|
|
expect(outcome.error).toMatchObject({
|
|
status: 403,
|
|
message: expect.stringContaining("no longer authorized"),
|
|
details: { code: "responsible_user_unauthorized" },
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
await expect(
|
|
db
|
|
.select({
|
|
outcome: connectionTokenIssuances.outcome,
|
|
tokenHash: connectionTokenIssuances.tokenHash,
|
|
errorCode: connectionTokenIssuances.errorCode,
|
|
})
|
|
.from(connectionTokenIssuances)
|
|
.where(eq(connectionTokenIssuances.connectionId, connection.id)),
|
|
).resolves.toEqual([
|
|
{
|
|
outcome: "failure",
|
|
tokenHash: null,
|
|
errorCode: "responsible_user_unauthorized",
|
|
},
|
|
]);
|
|
} finally {
|
|
releaseRevocation();
|
|
await revocation?.catch(() => undefined);
|
|
await mintDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
await revocationDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
}
|
|
}, 15_000);
|
|
|
|
it("denies token minting with an actionable error when the requesting agent has no install", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
await db
|
|
.delete(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
|
|
const res = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.set("X-Paperclip-Run-Id", run.id)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
code: "installation_required",
|
|
connection: { id: connection.id, name: connection.name },
|
|
remediation: {
|
|
action: "install_connection",
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
},
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [audit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.reasonCode, "installation_required"));
|
|
expect(audit).toMatchObject({
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
outcome: "failure",
|
|
});
|
|
});
|
|
|
|
it("accepts a company-wide install when minting a token", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
path: "static",
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
await db
|
|
.update(toolConnectionInstalls)
|
|
.set({ targetType: "company", targetId: company.id })
|
|
.where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
const res = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.set("X-Paperclip-Run-Id", run.id)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({
|
|
status: "use_env_lease",
|
|
connectionId: connection.id,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["generic", undefined],
|
|
["RFC 8693", "rfc8693" as const],
|
|
])(
|
|
"blocks a link-local %s token broker before credentials reach fetch",
|
|
async (_label, protocol) => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
tokenUrl: "http://169.254.169.254/latest/meta-data",
|
|
...(protocol ? { protocol } : {}),
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(
|
|
db,
|
|
agentJwtActor(company.id, agent.id, run.id),
|
|
);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(
|
|
new Error("the parent credential must never reach the broker"),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toMatchObject({ code: "remote_http_private_endpoint" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
connectionId: connection.id,
|
|
outcome: "failure",
|
|
errorCode: "remote_http_private_endpoint",
|
|
tokenHash: null,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("allows an explicitly allowlisted internal token broker through the guarded fetch", async () => {
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS",
|
|
"broker.example, 127.0.0.1",
|
|
);
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
tokenUrl: "http://127.0.0.1:8787/v1/tokens/exchange",
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
expect(String(url)).toBe("http://127.0.0.1:8787/v1/tokens/exchange");
|
|
expect(init).toMatchObject({
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: expect.objectContaining({
|
|
authorization: "Bearer parent-deploy-token",
|
|
}),
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: "allowlisted-child-token",
|
|
expires_in: 600,
|
|
scope: "pages:publish:ns/dotta",
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ token: "allowlisted-child-token" });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
parentScopes: ["staging", "production"],
|
|
});
|
|
const productionSecret = await secretService(db).create(company.id, {
|
|
provider: "local_encrypted",
|
|
name: `Production broker parent ${randomUUID()}`,
|
|
key: `broker.production.${randomUUID()}`,
|
|
value: "production-deploy-token",
|
|
});
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...connection.config,
|
|
tokenBroker: {
|
|
...(connection.config.tokenBroker as Record<string, unknown>),
|
|
parentCredentialConfigPath: "credentials.production_token",
|
|
},
|
|
},
|
|
credentialSecretRefs: [
|
|
...connection.credentialSecretRefs,
|
|
{
|
|
secretId: productionSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.production_token",
|
|
required: true,
|
|
label: "Production deploy token",
|
|
keyScope: "production",
|
|
},
|
|
],
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
await db.insert(companySecretBindings).values({
|
|
companyId: company.id,
|
|
secretId: productionSecret.id,
|
|
targetType: "tool_connection",
|
|
targetId: connection.id,
|
|
configPath: "credentials.production_token",
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: "unexpected-production-token",
|
|
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
|
scope: "staging",
|
|
}),
|
|
} as Response);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "staging" });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({ code: "parent_credential_missing" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const productionSecretEvents = await db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(
|
|
and(
|
|
eq(secretAccessEvents.consumerId, connection.id),
|
|
eq(secretAccessEvents.configPath, "credentials.production_token"),
|
|
),
|
|
);
|
|
expect(productionSecretEvents).toHaveLength(0);
|
|
|
|
fetchMock.mockClear();
|
|
fetchMock.mockImplementation(async (_url, init) => {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
authorization: "Bearer production-deploy-token",
|
|
}),
|
|
);
|
|
return {
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: "production-child-token",
|
|
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
|
scope: "production",
|
|
}),
|
|
} as Response;
|
|
});
|
|
|
|
const productionRes = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: ["production"] });
|
|
|
|
expect(productionRes.status).toBe(200);
|
|
expect(productionRes.body).toMatchObject({
|
|
token: "production-child-token",
|
|
scope: ["production"],
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns typed subject errors and rejects revoked grants immediately", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
const denied = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.send({ subject: { type: "user", userId: "someone-else" } });
|
|
expect(denied.status).toBe(403);
|
|
expect(denied.body).toMatchObject({
|
|
code: "subject_not_permitted",
|
|
connection: { uid: connection.uid },
|
|
subject: { type: "user", userId: "someone-else" },
|
|
});
|
|
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ credentialPolicy: "per_user" })
|
|
.where(eq(toolConnections.id, connection.id));
|
|
const missing = await request(app)
|
|
.post(
|
|
`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`,
|
|
)
|
|
.send({ subject: { type: "user", userId: "user-for-run" } });
|
|
expect(missing.status).toBe(409);
|
|
expect(missing.body).toMatchObject({
|
|
code: "user_authorization_required",
|
|
remediation: { action: "start_authorization" },
|
|
});
|
|
|
|
const [grant] = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "user-for-run",
|
|
status: "revoked",
|
|
isDefault: false,
|
|
})
|
|
.returning();
|
|
const revoked = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(revoked.status).toBe(409);
|
|
expect(revoked.body).toMatchObject({
|
|
code: "grant_revoked",
|
|
grantId: grant.id,
|
|
});
|
|
});
|
|
|
|
it("allows only the personal grant owner to create named-agent delegations and audits revocation", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
await grantBoardUser(db, company.id, "alice", [], "member");
|
|
await grantBoardUser(db, company.id, "mallory", [], "member");
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
const grant = await db
|
|
.insert(connectionGrants)
|
|
.values(personalGrantRow(company, connection))
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
await expect(
|
|
service.createConnectionGrantDelegation(
|
|
connection.id,
|
|
grant.id,
|
|
agent.id,
|
|
"mallory",
|
|
),
|
|
).rejects.toThrow(
|
|
"Only the active personal grant owner can create a delegation",
|
|
);
|
|
const delegation = await service.createConnectionGrantDelegation(
|
|
connection.id,
|
|
grant.id,
|
|
agent.id,
|
|
"alice",
|
|
);
|
|
expect(delegation).toMatchObject({
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: "alice",
|
|
});
|
|
|
|
await service.revokeConnectionGrantDelegation(
|
|
connection.id,
|
|
grant.id,
|
|
delegation.id,
|
|
{ actorType: "user", actorId: "manager" },
|
|
);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(connectionGrantDelegations)
|
|
.where(eq(connectionGrantDelegations.id, delegation.id)),
|
|
).toHaveLength(0);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.connectionId, connection.id)),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "connection_grant.delegated",
|
|
actorId: "alice",
|
|
}),
|
|
expect.objectContaining({
|
|
action: "connection_grant.delegation_revoked",
|
|
actorId: "manager",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("enforces delegation owner and manager permissions through the HTTP routes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
await grantBoardUser(db, company.id, "alice", [], "member");
|
|
await grantBoardUser(db, company.id, "mallory", [], "member");
|
|
await grantBoardUser(
|
|
db,
|
|
company.id,
|
|
"manager",
|
|
["tools:manage_connections"],
|
|
"operator",
|
|
);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
const grant = await db
|
|
.insert(connectionGrants)
|
|
.values(personalGrantRow(company, connection))
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
|
|
const nonOwner = await request(
|
|
createRouteApp(db, boardSessionActor(company.id, "member", "mallory")),
|
|
)
|
|
.post(
|
|
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`,
|
|
)
|
|
.send({ agentId: agent.id });
|
|
expect(nonOwner.status).toBe(403);
|
|
|
|
const created = await request(
|
|
createRouteApp(db, boardSessionActor(company.id, "member", "alice")),
|
|
)
|
|
.post(
|
|
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`,
|
|
)
|
|
.send({ agentId: agent.id });
|
|
expect(created.status).toBe(201);
|
|
expect(created.body).toMatchObject({
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
});
|
|
|
|
const unrelatedRevoke = await request(
|
|
createRouteApp(db, boardSessionActor(company.id, "member", "mallory")),
|
|
).delete(
|
|
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
|
|
);
|
|
expect(unrelatedRevoke.status).toBe(403);
|
|
|
|
const managerRevoke = await request(
|
|
createRouteApp(db, boardSessionActor(company.id, "operator", "manager")),
|
|
).delete(
|
|
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
|
|
);
|
|
expect(managerRevoke.status).toBe(200);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(connectionGrantDelegations)
|
|
.where(eq(connectionGrantDelegations.id, created.body.id)),
|
|
).toHaveLength(0);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.connectionId, connection.id)),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "connection_grant.delegated",
|
|
actorId: "alice",
|
|
}),
|
|
expect.objectContaining({
|
|
action: "connection_grant.delegation_revoked",
|
|
actorId: "manager",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("prevents an unrelated member from health-checking a per-user connection", async () => {
|
|
const company = await createCompany(db);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialPolicy: "per_user",
|
|
createdByUserId: "alice",
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
await db.insert(connectionGrants).values(personalGrantRow(company, connection));
|
|
|
|
const response = await request(
|
|
createRouteApp(db, boardSessionActor(company.id, "member", "mallory")),
|
|
).post(`/api/tool-connections/${connection.id}/health-check`);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.error).toContain("Missing permission: tools:manage_connections");
|
|
});
|
|
|
|
it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
await grantBoardUser(db, company.id, "alice", [], "member");
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
const grant = await db
|
|
.insert(connectionGrants)
|
|
.values(personalGrantRow(company, connection))
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const serviceDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
const service = createTestToolAccessService(serviceDb);
|
|
const removalDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
let releaseRemoval!: () => void;
|
|
const removalMayCommit = new Promise<void>((resolve) => {
|
|
releaseRemoval = resolve;
|
|
});
|
|
let membershipLocked!: () => void;
|
|
const membershipIsLocked = new Promise<void>((resolve) => {
|
|
membershipLocked = resolve;
|
|
});
|
|
|
|
const removal = removalDb.transaction(async (tx) => {
|
|
await tx.execute(sql`
|
|
SELECT "id"
|
|
FROM "company_memberships"
|
|
WHERE "company_id" = ${company.id}
|
|
AND "principal_type" = 'user'
|
|
AND "principal_id" = 'alice'
|
|
FOR UPDATE
|
|
`);
|
|
await tx.execute(sql`
|
|
UPDATE "connection_grants"
|
|
SET "status" = 'revoked', "revoked_at" = now(), "updated_at" = now()
|
|
WHERE "id" = ${grant.id}
|
|
`);
|
|
await tx.execute(sql`
|
|
DELETE FROM "connection_grant_delegations"
|
|
WHERE "company_id" = ${company.id} AND "grant_id" = ${grant.id}
|
|
`);
|
|
membershipLocked();
|
|
await removalMayCommit;
|
|
await tx.execute(sql`
|
|
UPDATE "company_memberships"
|
|
SET "status" = 'suspended', "updated_at" = now()
|
|
WHERE "company_id" = ${company.id}
|
|
AND "principal_type" = 'user'
|
|
AND "principal_id" = 'alice'
|
|
`);
|
|
});
|
|
|
|
await membershipIsLocked;
|
|
let creationSettled = false;
|
|
const creation = service
|
|
.createConnectionGrantDelegation(
|
|
connection.id,
|
|
grant.id,
|
|
agent.id,
|
|
"alice",
|
|
)
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
)
|
|
.finally(() => {
|
|
creationSettled = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(creationSettled).toBe(false);
|
|
releaseRemoval();
|
|
await removal;
|
|
const creationResult = await creation;
|
|
expect(creationResult.error).toEqual(
|
|
expect.objectContaining({
|
|
message:
|
|
"Only an active company member can delegate their personal grant",
|
|
}),
|
|
);
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "active" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "alice"),
|
|
),
|
|
);
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "active", revokedAt: null })
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(connectionGrantDelegations)
|
|
.where(eq(connectionGrantDelegations.grantId, grant.id)),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("uses the responsible user's personal grant for autonomous token minting", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ invocationSource: "automation" })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ credentialPolicy: "per_user" })
|
|
.where(eq(toolConnections.id, connection.id));
|
|
const grant = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "user-for-run",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: false,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: "responsible-user-child-token",
|
|
expires_in: 600,
|
|
scope: "pages:publish:ns/dotta",
|
|
}),
|
|
} as Response);
|
|
|
|
const allowed = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
expect(allowed.status).toBe(200);
|
|
expect(allowed.body).toMatchObject({
|
|
token: "responsible-user-child-token",
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issue.id)),
|
|
).toEqual([]);
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "suspended" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
const inactiveOwner = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(inactiveOwner.status).toBe(403);
|
|
expect(inactiveOwner.body.error).toContain("no longer authorized");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("enforces organization grant audiences at token mint time", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const [grant] = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
})
|
|
.returning();
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: grant!.id,
|
|
subjectType: "user",
|
|
subjectId: "user-for-run",
|
|
});
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({ token: "audience-token", expires_in: 600 }),
|
|
} as Response);
|
|
|
|
const allowed = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(allowed.status).toBe(200);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "suspended" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
const inactiveAudienceMember = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(inactiveAudienceMember.status).toBe(403);
|
|
expect(inactiveAudienceMember.body.error).toContain("no longer authorized");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "active" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
|
|
await db
|
|
.delete(connectionGrantMembers)
|
|
.where(eq(connectionGrantMembers.grantId, grant!.id));
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: grant!.id,
|
|
subjectType: "user",
|
|
subjectId: "sales-user",
|
|
});
|
|
const denied = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(denied.status).toBe(403);
|
|
expect(denied.body).toMatchObject({
|
|
code: "grant_audience_denied",
|
|
grantId: grant!.id,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["suspend", "archive", "remove"] as const)(
|
|
"keeps an organization audience fail-closed when empty replacement races with member %s",
|
|
async (cleanupKind) => {
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "owner", [], "owner");
|
|
await grantBoardUser(db, company.id, "previous-user", [], "member");
|
|
await grantBoardUser(db, company.id, "departing-user", [], "member");
|
|
const departingMembership = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, "departing-user"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0]!);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const grant = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
subjectType: "user",
|
|
subjectId: "previous-user",
|
|
});
|
|
|
|
const firstReplacementDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const replacementDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const cleanupDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const replacementService = createTestToolAccessService(replacementDb);
|
|
const firstReplacementPid = (await firstReplacementDb.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0]!.pid;
|
|
const cleanupPid = (await cleanupDb.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0]!.pid;
|
|
const replacementPid = (await replacementDb.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0]!.pid;
|
|
const waitForFirstReplacementLock = (waitingPid: number) => vi.waitFor(async () => {
|
|
const [waiting] = await db.execute<{ blockers: number[] }>(sql`select pg_blocking_pids(${waitingPid}) as blockers`);
|
|
expect(waiting!.blockers).toContain(firstReplacementPid);
|
|
}, { timeout: 5000 });
|
|
let releaseFirstReplacement!: () => void;
|
|
const firstReplacementMayCommit = new Promise<void>((resolve) => {
|
|
releaseFirstReplacement = resolve;
|
|
});
|
|
let firstReplacementStaged!: () => void;
|
|
const firstReplacementIsStaged = new Promise<void>((resolve) => {
|
|
firstReplacementStaged = resolve;
|
|
});
|
|
let releaseCleanup!: () => void;
|
|
const cleanupMayFinish = new Promise<void>((resolve) => {
|
|
releaseCleanup = resolve;
|
|
});
|
|
let membershipLocked!: () => void;
|
|
const membershipIsLocked = new Promise<void>((resolve) => {
|
|
membershipLocked = resolve;
|
|
});
|
|
|
|
const firstReplacement = firstReplacementDb.transaction(async (tx) => {
|
|
const service = createTestToolAccessService(
|
|
tx as unknown as ReturnType<typeof createDb>,
|
|
);
|
|
await service.replaceConnectionGrantMembers(connection.id, grant.id, [
|
|
"departing-user",
|
|
]);
|
|
firstReplacementStaged();
|
|
await firstReplacementMayCommit;
|
|
});
|
|
await firstReplacementIsStaged;
|
|
|
|
const cleanup = cleanupDb.transaction(async (tx) => {
|
|
await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, departingMembership.id))
|
|
.for("update");
|
|
membershipLocked();
|
|
await cleanupMayFinish;
|
|
|
|
const access = accessService(
|
|
tx as unknown as ReturnType<typeof createDb>,
|
|
);
|
|
if (cleanupKind === "suspend") {
|
|
await access.updateMemberAndPermissions(
|
|
company.id,
|
|
departingMembership.id,
|
|
{ status: "suspended", grants: [] },
|
|
"owner",
|
|
);
|
|
} else if (cleanupKind === "archive") {
|
|
await access.archiveMember(company.id, departingMembership.id);
|
|
} else {
|
|
await access.setUserCompanyAccess("departing-user", []);
|
|
}
|
|
});
|
|
|
|
let replacementFinished: Promise<unknown> | undefined;
|
|
try {
|
|
await waitForFirstReplacementLock(cleanupPid);
|
|
let replacementSettled = false;
|
|
const replacement = replacementService
|
|
.replaceConnectionGrantMembers(connection.id, grant.id, [])
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
)
|
|
.finally(() => {
|
|
replacementSettled = true;
|
|
});
|
|
replacementFinished = replacement;
|
|
await waitForFirstReplacementLock(replacementPid);
|
|
expect(replacementSettled).toBe(false);
|
|
|
|
releaseFirstReplacement();
|
|
await firstReplacement;
|
|
await membershipIsLocked;
|
|
expect(replacementSettled).toBe(false);
|
|
releaseCleanup();
|
|
await cleanup;
|
|
const replacementResult = await replacement;
|
|
expect(replacementResult.value).toBeNull();
|
|
expect(replacementResult.error).toEqual(
|
|
expect.objectContaining({
|
|
message:
|
|
"Replace inactive audience members before widening access to the whole company",
|
|
status: 409,
|
|
details: {
|
|
code: "audience_widening_blocked",
|
|
inactiveUserIds: ["departing-user"],
|
|
},
|
|
}),
|
|
);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(connectionGrantMembers)
|
|
.where(eq(connectionGrantMembers.grantId, grant.id)),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
subjectType: "user",
|
|
subjectId: "departing-user",
|
|
}),
|
|
]);
|
|
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(
|
|
mcpHttpResponse({ token: "unexpected-company-wide-token" }),
|
|
);
|
|
const denied = await request(
|
|
createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)),
|
|
)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(denied.status).toBe(403);
|
|
expect(denied.body).toMatchObject({
|
|
code: "grant_audience_denied",
|
|
grantId: grant.id,
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
releaseFirstReplacement();
|
|
releaseCleanup();
|
|
await Promise.allSettled([firstReplacement, cleanup, ...(replacementFinished ? [replacementFinished] : [])]);
|
|
await Promise.all([firstReplacementDb.$client.end(), replacementDb.$client.end(), cleanupDb.$client.end()]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("returns daily connection usage buckets", async () => {
|
|
const company = await createCompany(db);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
await db.insert(connectionTokenIssuances).values({
|
|
companyId: company.id,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
agentId: (await createAgent(db, company.id)).id,
|
|
path: "exchange",
|
|
requestedScope: [],
|
|
issuedScope: [],
|
|
outcome: "success",
|
|
});
|
|
await db.insert(toolInvocations).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
toolName: "fixture",
|
|
riskLevel: "write",
|
|
});
|
|
const usage = await service.getConnectionUsage(
|
|
connection.uid,
|
|
"7d",
|
|
company.id,
|
|
);
|
|
expect(usage.connection).toEqual({
|
|
id: connection.id,
|
|
uid: connection.uid,
|
|
});
|
|
expect(usage.buckets.at(-1)).toMatchObject({
|
|
issuances: {
|
|
total: 1,
|
|
byOutcome: { success: 1 },
|
|
byPath: { exchange: 1 },
|
|
},
|
|
invocations: { total: 1, byRiskLevel: { write: 1 } },
|
|
});
|
|
});
|
|
|
|
it("rejects connection token minting after the heartbeat run completes", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded" })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(new Error("inactive runs must not call upstream"));
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.set("X-Paperclip-Run-Id", run.id)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toBe("Agent run is not active");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("denies broker minting when the agent only has a generic connection profile grant", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id, {
|
|
brokerMint: false,
|
|
});
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(new Error("broker mint should not call upstream"));
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "broker_mint_not_granted" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
connectionId: connection.id,
|
|
path: "exchange",
|
|
outcome: "denied",
|
|
errorCode: "broker_mint_not_granted",
|
|
tokenHash: null,
|
|
});
|
|
});
|
|
|
|
it("does not infer oauth_access for OAuth-backed connections without broker opt-in", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createOAuthConnection(db, company.id);
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(
|
|
new Error("oauth broker refusal should not call upstream"),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "channels:write" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "broker_not_enabled" });
|
|
expect(JSON.stringify(res.body)).not.toContain(
|
|
"stored-upstream-oauth-access-token",
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
connectionId: connection.id,
|
|
path: "static",
|
|
outcome: "denied",
|
|
errorCode: "broker_not_enabled",
|
|
tokenHash: null,
|
|
});
|
|
expect(issuance?.path).not.toBe("oauth_access");
|
|
const secretEvents = await db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(eq(secretAccessEvents.consumerId, connection.id));
|
|
expect(secretEvents).toHaveLength(0);
|
|
});
|
|
|
|
it("refuses explicit oauth_access broker paths without projecting stored OAuth bearers", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createOAuthConnection(db, company.id, {
|
|
tokenBroker: {
|
|
enabled: true,
|
|
path: "oauth_access",
|
|
parentScopes: ["channels:write"],
|
|
defaultScopes: ["channels:write"],
|
|
},
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(
|
|
new Error("oauth_access refusal should not call upstream"),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "channels:write" });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
code: "oauth_access_projection_disabled",
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(
|
|
"stored-upstream-oauth-access-token",
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
connectionId: connection.id,
|
|
path: "oauth_access",
|
|
outcome: "denied",
|
|
errorCode: "oauth_access_projection_disabled",
|
|
tokenHash: null,
|
|
});
|
|
const secretEvents = await db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(eq(secretAccessEvents.consumerId, connection.id));
|
|
expect(secretEvents).toHaveLength(0);
|
|
});
|
|
|
|
it("returns a typed use_env_lease refusal for static credential delivery", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
path: "static",
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({
|
|
status: "use_env_lease",
|
|
code: "use_env_lease",
|
|
path: "static",
|
|
connectionId: connection.id,
|
|
});
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
connectionId: connection.id,
|
|
path: "static",
|
|
outcome: "use_env_lease",
|
|
errorCode: "use_env_lease",
|
|
tokenHash: null,
|
|
});
|
|
});
|
|
|
|
it("denies token scopes outside the parent scope before minting", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
parentScopes: ["pages:publish:ns/dotta"],
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
|
|
const res = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/other" });
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({ code: "scope_exceeds_parent" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [issuance] = await db.select().from(connectionTokenIssuances);
|
|
expect(issuance).toMatchObject({
|
|
outcome: "denied",
|
|
errorCode: "scope_exceeds_parent",
|
|
tokenHash: null,
|
|
});
|
|
});
|
|
|
|
it("rate limits connection token minting per agent and connection", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const { connection } = await createBrokerConnection(db, company.id, {
|
|
rateLimitPerHour: 1,
|
|
});
|
|
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
token: `child-${randomUUID()}`,
|
|
expires_in: 600,
|
|
scope: "pages:publish:ns/dotta",
|
|
}),
|
|
} as Response);
|
|
|
|
await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" })
|
|
.expect(200);
|
|
|
|
const limited = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({ scope: "pages:publish:ns/dotta" });
|
|
|
|
expect(limited.status).toBe(429);
|
|
expect(limited.body).toMatchObject({ code: "rate_limited" });
|
|
const issuances = await db
|
|
.select()
|
|
.from(connectionTokenIssuances)
|
|
.where(eq(connectionTokenIssuances.connectionId, connection.id));
|
|
expect(issuances.map((row) => row.outcome).sort()).toEqual([
|
|
"rate_limited",
|
|
"success",
|
|
]);
|
|
});
|
|
|
|
it("quarantines new or changed catalog entries during active opt-in catalog refresh", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{
|
|
name: "search_notes",
|
|
description: "Search notes.",
|
|
inputSchema: { type: "object", properties: { q: { type: "string" } } },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "send_email",
|
|
description: "Send an email.",
|
|
inputSchema: { type: "object", properties: { to: { type: "string" } } },
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]);
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Remote fixture",
|
|
transport: "mcp_remote",
|
|
config: {
|
|
url: "https://fixture.example/mcp",
|
|
quarantineNewEntries: true,
|
|
},
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const firstRefresh = await service.refreshCatalog(connection.id, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://fixture.example/mcp",
|
|
expect.objectContaining({ method: "POST" }),
|
|
);
|
|
expect(firstRefresh.discoveredCount).toBe(2);
|
|
expect(firstRefresh.quarantinedCount).toBe(2);
|
|
expect(firstRefresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_notes",
|
|
status: "quarantined",
|
|
riskLevel: "read",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "send_email",
|
|
status: "quarantined",
|
|
riskLevel: "write",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
status: "active",
|
|
reviewedAt: new Date(),
|
|
quarantineReason: null,
|
|
quarantinedAt: null,
|
|
})
|
|
.where(eq(toolCatalogEntries.toolName, "send_email"));
|
|
fetchMock.mockResolvedValueOnce(
|
|
mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "send_email",
|
|
description: "Send an email with attachments.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
to: { type: "string" },
|
|
attachment: { type: "string" },
|
|
},
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
])),
|
|
);
|
|
|
|
const secondRefresh = await service.refreshCatalog(connection.id);
|
|
|
|
expect(secondRefresh.quarantinedCount).toBe(1);
|
|
expect(secondRefresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "send_email",
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("persists generic instructions through PATCH with configuration permissions and company isolation", async () => {
|
|
const company = await createCompany(db);
|
|
const other = await createCompany(db);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const settings = { enabled: true, text: "Use the release handbook and cite the checklist." };
|
|
const app = createRouteApp(db);
|
|
const endpoint = `/api/tool-connections/${connection.id}`;
|
|
await request(app).patch(endpoint).send({ agentInstructions: settings }).expect(200);
|
|
expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual(settings);
|
|
await request(app).patch(endpoint).send({ agentInstructions: { ...settings, text: " " } }).expect(400);
|
|
await request(app).patch(endpoint).send({ agentInstructions: { ...settings, enabled: false } }).expect(200);
|
|
expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual({ ...settings, enabled: false });
|
|
const viewer = `viewer-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, viewer, [], "viewer");
|
|
await request(createRouteApp(db, boardSessionActor(company.id, "viewer", viewer))).patch(endpoint).send({ agentInstructions: settings }).expect(403);
|
|
await request(createRouteApp(db, boardSessionActor(other.id, "owner"))).patch(endpoint).send({ agentInstructions: settings }).expect(404);
|
|
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, connection.id));
|
|
expect(events.some(event => event.details?.agentInstructionsChanged === true)).toBe(true);
|
|
});
|
|
|
|
it("persists supplied defaults, retains them on catalog refresh, and requires Honcho workspace at setup", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([{ name: "search", annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { workspace_id: { type: "string" } } } }]);
|
|
await expect(service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" } })).rejects.toMatchObject({ status: 400 });
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" }, configValues: { workspaceId: "fixture-workspace" } });
|
|
const template = getConnectableAppDefinition("honcho")!.agentInstructions!;
|
|
expect(connected.connection.agentInstructions).toEqual({ enabled: true, text: template.text, template: { id: template.id, version: template.version } });
|
|
await service.updateConnection(connected.connectionId, { agentInstructions: { enabled: false, text: "Keep this custom guidance." } });
|
|
await service.refreshCatalog(connected.connectionId);
|
|
expect((await service.getConnection(connected.connectionId)).agentInstructions).toEqual({ enabled: false, text: "Keep this custom guidance." });
|
|
});
|
|
|
|
it("preserves custom instructions and opt-outs through OAuth draft recovery and reconnect", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const settings = { enabled: false, text: "Look up the current decision before editing it." };
|
|
const input = { galleryKey: "google-chat", connectionMethodKey: "customer-read-oauth", grantKind: "user" as const, oauthClient: { clientId: "instructions-client", clientSecret: "instructions-secret" }, agentInstructions: settings };
|
|
const initial = await service.connectGalleryApp(company.id, input, actor);
|
|
const { agentInstructions: _settings, ...retained } = input;
|
|
const resumed = await service.connectGalleryApp(company.id, { ...retained, resumeConnectionId: initial.connectionId }, actor);
|
|
expect(resumed.connection.agentInstructions).toEqual(settings);
|
|
const started = await service.startOAuth(company.id, resumed.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor });
|
|
expect(started.authorizationUrl).toBeTruthy();
|
|
expect((await service.getConnection(initial.connectionId)).agentInstructions).toEqual(settings);
|
|
await db.update(toolConnections).set({ status: "active", enabled: true }).where(eq(toolConnections.id, initial.connectionId));
|
|
const reconnected = await service.connectGalleryApp(company.id, { ...retained, reconnectConnectionId: initial.connectionId }, actor);
|
|
expect(reconnected.connection.agentInstructions).toEqual(settings);
|
|
});
|
|
|
|
it.each(["managed", "customer"] as const)("preserves a %s Workspace draft's selection after declined and expired authorization", async (mode) => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-draft-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", grantKind: "user",
|
|
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
|
...(mode === "customer" ? { oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" } } : {}),
|
|
}, actor);
|
|
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
|
const [unstarted] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
const configWithProposals = { ...unstarted.config, oauth: { ...(unstarted.config.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: {
|
|
[userId]: { requestedScopes: googleWorkspaceSelectedScopes({ drive: "write" }), requestedAt: "2026-10-26T10:00:00Z" },
|
|
other: { requestedScopes: googleWorkspaceSelectedScopes({ calendar: "write" }), requestedAt: "2026-10-26T10:00:00Z" },
|
|
} } };
|
|
await db.update(toolConnections).set({ config: configWithProposals, transportConfig: configWithProposals }).where(eq(toolConnections.id, connected.connectionId));
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor, workspaceServices: { gmail: "read" },
|
|
});
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
|
if (mode === "managed") {
|
|
await expect(service.completePaperclipCloudConnectorCallback({ state, error: "access_denied", actor })).rejects.toMatchObject({ status: 400 });
|
|
} else {
|
|
await expect(service.completeOAuthCallback({ state, error: "access_denied", redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
|
}
|
|
const draft = await service.getConnection(connected.connectionId);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.config.workspaceSetup).toEqual({
|
|
version: 1, companyId: company.id, connectionId: draft.id, actorType: "user", actorId: userId,
|
|
grantKind: "user", subjectId: userId,
|
|
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
|
|
});
|
|
expect((draft.config.oauth as typeof configWithProposals.oauth).pendingWorkspaceServiceRequests).toEqual({ other: configWithProposals.oauth.pendingWorkspaceServiceRequests.other });
|
|
const retained = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", resumeConnectionId: draft.id,
|
|
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
|
...(mode === "customer" ? { oauthClient: { clientId: "fixture-client" } } : {}),
|
|
}, actor);
|
|
expect(retained.connectionId).toBe(draft.id);
|
|
const resumed = await service.startOAuth(company.id, retained.connectionId, { redirectUri, actor });
|
|
const [resumedState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, new URL(resumed.authorizationUrl).searchParams.get("state")!));
|
|
expect(resumedState.requestedScopes).toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
|
|
await db.update(toolOauthStates).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolOauthStates.state, resumedState.state));
|
|
if (mode === "managed") {
|
|
await expect(service.completePaperclipCloudConnectorCallback({ state: resumedState.state, claimId: "expired", actor })).rejects.toMatchObject({ status: 400 });
|
|
} else {
|
|
await expect(service.completeOAuthCallback({ state: resumedState.state, code: "expired", redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
|
}
|
|
const coldService = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const coldResume = await coldService.startOAuth(company.id, draft.id, { redirectUri, actor });
|
|
const [coldState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, new URL(coldResume.authorizationUrl).searchParams.get("state")!));
|
|
expect(coldState.requestedScopes).toEqual(["https://www.googleapis.com/auth/gmail.readonly"]);
|
|
});
|
|
|
|
it.each(["bound", "first-use"] as const)("preserves the latest Workspace draft choice across overlapping %s customer starts", async (bindingMode) => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-overlap-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
|
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
|
}, actor);
|
|
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
|
const endpoints = {
|
|
provider: "google", authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
tokenUrl: "https://oauth2.googleapis.com/token", registrationUrl: null,
|
|
scopes: googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION),
|
|
offlineAccessSupported: true, issuer: "https://accounts.google.com", resource: null,
|
|
};
|
|
const getConnectionRow = async () => {
|
|
const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
return row;
|
|
};
|
|
const initial = await getConnectionRow();
|
|
if (bindingMode === "bound") {
|
|
const config = { ...initial.config, oauth: { ...(initial.config.oauth as Record<string, unknown>),
|
|
clientRedirectUri: redirectUri, clientIssuer: endpoints.issuer, clientResource: null, clientCompanyId: company.id,
|
|
} };
|
|
await db.update(toolConnections).set({ config, transportConfig: config }).where(eq(toolConnections.id, initial.id));
|
|
} else {
|
|
expect((initial.config.oauth as Record<string, unknown>).clientRedirectUri).toBeUndefined();
|
|
}
|
|
const observedClientRows: typeof initial[] = [];
|
|
const ensureOAuthClient = createOAuthClientRegistration(db, {
|
|
now: () => new Date(), options: {}, getConnectionRow,
|
|
requestOAuthClientRegistration: vi.fn(),
|
|
store: {
|
|
createOrRotateOAuthSecret: vi.fn(), syncCredentialBindings: vi.fn(),
|
|
oauthClientForConnection: async (row) => {
|
|
observedClientRows.push(row);
|
|
return { clientId: "fixture-client", clientSecret: "fixture-secret", clientIdEnv: null, clientSecretEnv: null };
|
|
},
|
|
},
|
|
});
|
|
let releaseA!: () => void;
|
|
let reachedA!: () => void;
|
|
const pausedA = new Promise<void>(resolve => { reachedA = resolve; });
|
|
const resumeA = new Promise<void>(resolve => { releaseA = resolve; });
|
|
let discoveries = 0;
|
|
const makeStart = () => createOAuthAuthorizationStart(db, {
|
|
now: () => new Date(), getConnectionRow, ensureOAuthClient,
|
|
setupPolicy: createConnectionSetupPolicy(db), fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
|
currentCloudConnector: () => null, vercelConnect: null,
|
|
oauthEndpointsForConnection: async () => {
|
|
if (++discoveries === 1) { reachedA(); await resumeA; }
|
|
return endpoints;
|
|
},
|
|
assertOAuthEndpointUrl: (_kind, url) => String(url),
|
|
});
|
|
const start = makeStart();
|
|
const first = start(company.id, initial.id, { redirectUri, actor, subjectUserId: userId, workspaceServices: { gmail: "write" } });
|
|
let startedA;
|
|
let startedB;
|
|
let afterB;
|
|
try {
|
|
await pausedA;
|
|
startedB = await start(company.id, initial.id, { redirectUri, actor, subjectUserId: userId, workspaceServices: { gmail: "read" } });
|
|
afterB = await getConnectionRow();
|
|
expect((afterB.config.oauth as Record<string, unknown>).clientRedirectUri).toBe(redirectUri);
|
|
const pending = { anotherUser: { requestedScopes: googleWorkspaceSelectedScopes({ calendar: "read" }), requestedAt: "2026-10-30T16:00:00Z" } };
|
|
await db.update(toolConnections).set({
|
|
config: { ...afterB.config, concurrentSetting: "config-b", oauth: { ...(afterB.config.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: pending } },
|
|
transportConfig: { ...afterB.transportConfig, transportOnlySetting: "transport-b", oauth: { ...(afterB.transportConfig.oauth as Record<string, unknown>), pendingWorkspaceServiceRequests: pending } },
|
|
}).where(eq(toolConnections.id, initial.id));
|
|
} finally {
|
|
releaseA();
|
|
startedA = await first;
|
|
}
|
|
const writeScopes = googleWorkspaceSelectedScopes({ gmail: "write" });
|
|
const readScopes = googleWorkspaceSelectedScopes({ gmail: "read" });
|
|
const expectedBinding = { version: 1, companyId: company.id, connectionId: initial.id, actorType: "user", actorId: userId,
|
|
grantKind: "user", subjectId: userId, requestedScopes: readScopes };
|
|
if (bindingMode === "first-use") {
|
|
expect(observedClientRows).toHaveLength(2);
|
|
expect((observedClientRows[1].config.oauth as Record<string, unknown>).clientRedirectUri).toBe(redirectUri);
|
|
expect(observedClientRows[1].config.workspaceSetup).toEqual(expectedBinding);
|
|
}
|
|
const latest = await getConnectionRow();
|
|
expect(afterB!.config.workspaceSetup).toEqual(expectedBinding);
|
|
expect(latest.config.workspaceSetup).toEqual(expectedBinding);
|
|
expect(latest.transportConfig.workspaceSetup).toEqual(expectedBinding);
|
|
expect(latest.config.concurrentSetting).toBe("config-b");
|
|
expect(latest.transportConfig.transportOnlySetting).toBe("transport-b");
|
|
for (const config of [latest.config, latest.transportConfig]) {
|
|
expect((config.oauth as Record<string, unknown>).pendingWorkspaceServiceRequests).toEqual({ anotherUser: {
|
|
requestedScopes: googleWorkspaceSelectedScopes({ calendar: "read" }), requestedAt: "2026-10-30T16:00:00Z",
|
|
} });
|
|
}
|
|
for (const [started, expected] of [[startedA!, writeScopes], [startedB!, readScopes]] as const) {
|
|
const url = new URL(started.authorizationUrl);
|
|
expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected);
|
|
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
|
expect(state.requestedScopes).toEqual(expected);
|
|
}
|
|
const cold = await makeStart()(company.id, initial.id, { redirectUri, actor, subjectUserId: userId });
|
|
const coldUrl = new URL(cold.authorizationUrl);
|
|
expect(coldUrl.searchParams.get("scope")?.split(" ")).toEqual(readScopes);
|
|
const [coldState] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, coldUrl.searchParams.get("state")!));
|
|
expect(coldState.requestedScopes).toEqual(readScopes);
|
|
});
|
|
|
|
it.each((["clientId", "clientSecretRef", "clientIssuer", "clientResource", "clientRedirectUri", "issuer", "resource"] as const)
|
|
.flatMap(field => (["discovery", "client-resolution"] as const).map(stage => [field, stage] as const)))(
|
|
"rejects a concurrent %s change during Workspace %s", async (field, stage) => {
|
|
const company = await createCompany(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "organization",
|
|
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
|
}, actor);
|
|
const getConnectionRow = async () => {
|
|
const [row] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
return row;
|
|
};
|
|
const endpoints = { provider: "google", authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
tokenUrl: "https://oauth2.googleapis.com/token", scopes: googleWorkspaceSelectedScopes({ gmail: "read" }),
|
|
issuer: "https://accounts.google.com", resource: null };
|
|
const ensureClient = createOAuthClientRegistration(db, {
|
|
now: () => new Date(), options: {}, getConnectionRow, requestOAuthClientRegistration: vi.fn(),
|
|
store: { createOrRotateOAuthSecret: vi.fn(), syncCredentialBindings: vi.fn(),
|
|
oauthClientForConnection: async () => ({ clientId: "fixture-client", clientSecret: "fixture-secret", clientIdEnv: null, clientSecretEnv: null }) },
|
|
});
|
|
let changed: Awaited<ReturnType<typeof getConnectionRow>>;
|
|
const mutateClient = async () => {
|
|
const row = await getConnectionRow();
|
|
const updates = field === "clientSecretRef"
|
|
? { credentialSecretRefs: row.credentialSecretRefs.map(ref => ref.configPath === "oauth.client_secret" ? { ...ref, versionSelector: 1 } : ref) }
|
|
: {
|
|
config: { ...row.config, oauth: { ...(row.config.oauth as Record<string, unknown>), [field]: `changed-${field}` } },
|
|
transportConfig: { ...row.transportConfig, oauth: { ...(row.transportConfig.oauth as Record<string, unknown>), [field]: `changed-${field}` } },
|
|
};
|
|
if (field === "clientSecretRef") expect(updates.credentialSecretRefs).not.toEqual(row.credentialSecretRefs);
|
|
[changed] = await db.update(toolConnections).set(updates).where(eq(toolConnections.id, row.id)).returning();
|
|
};
|
|
const start = createOAuthAuthorizationStart(db, {
|
|
now: () => new Date(), getConnectionRow,
|
|
setupPolicy: createConnectionSetupPolicy(db), fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
|
currentCloudConnector: () => null, vercelConnect: null,
|
|
oauthEndpointsForConnection: async () => { if (stage === "discovery") await mutateClient(); return endpoints; },
|
|
ensureOAuthClient: async input => { const resolved = await ensureClient(input); if (stage === "client-resolution") await mutateClient(); return resolved; },
|
|
assertOAuthEndpointUrl: (_kind, url) => String(url),
|
|
});
|
|
await expect(start(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { gmail: "read" },
|
|
})).rejects.toMatchObject({ status: 409 });
|
|
const retained = await getConnectionRow();
|
|
expect(retained.config).toEqual(changed!.config);
|
|
expect(retained.transportConfig).toEqual(changed!.transportConfig);
|
|
expect(retained.credentialSecretRefs).toEqual(changed!.credentialSecretRefs);
|
|
},
|
|
);
|
|
|
|
it.each(["user", "organization"] as const)("preserves the %s Workspace request when account-page reconnect omits a selection", async (kind) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: kind,
|
|
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
|
}, actor);
|
|
const requestedScopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
|
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
|
|
await db.update(connectionGrants).set({
|
|
providerTenant: { oauth: { requestedScopes, scopes: [...requestedScopes, "https://www.googleapis.com/auth/documents"] } },
|
|
status: "needs_reauthorization",
|
|
}).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, kind)));
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
|
});
|
|
const url = new URL(started.authorizationUrl);
|
|
expect(url.searchParams.get("scope")?.split(" ")).toEqual(requestedScopes);
|
|
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
|
expect(state.requestedScopes).toEqual(requestedScopes);
|
|
await db.update(connectionGrants).set({ providerTenant: {} })
|
|
.where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
await expect(service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
|
})).rejects.toMatchObject({ status: 400 });
|
|
const selected = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor,
|
|
workspaceServices: { gmail: "read" },
|
|
});
|
|
expect(new URL(selected.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(requestedScopes);
|
|
});
|
|
|
|
it("requires explicit selection for historical Workspace drafts and a different setup owner", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-owner-${randomUUID()}`;
|
|
const otherUserId = `workspace-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
await grantBoardUser(db, company.id, otherUserId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind: "organization",
|
|
}, actor);
|
|
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
|
await expect(service.startOAuth(company.id, connected.connectionId, { redirectUri, actor })).rejects.toMatchObject({ status: 400 });
|
|
await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: { gmail: "read" } });
|
|
await expect(service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: otherUserId },
|
|
})).rejects.toMatchObject({ status: 400 });
|
|
expect((await service.getConnection(connected.connectionId)).config.workspaceSetup).toMatchObject({ actorId: userId });
|
|
});
|
|
|
|
it.each(["credentialPolicy", "credentialSource", "authKind", "transport", "createdByUserId", "sourceTemplateKey", "connectionMethodKey"] as const)("rejects a Workspace draft whose %s changes during authorization", async (field) => {
|
|
const company = await createCompany(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "organization",
|
|
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
|
}, actor);
|
|
const [original] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
const changed: Partial<typeof toolConnections.$inferInsert> = {};
|
|
if (field === "credentialPolicy") changed.credentialPolicy = "per_user";
|
|
else if (field === "credentialSource") {
|
|
changed.credentialSource = "vercel_connect";
|
|
changed.credentialRefs = [];
|
|
changed.credentialSecretRefs = [];
|
|
changed.externalCredential = { provider: "vercel_connect", connectorId: "fixture", connectorUid: "fixture", service: "google", principalMode: "user" };
|
|
}
|
|
else if (field === "authKind") changed.authKind = "none";
|
|
else if (field === "transport") changed.transport = "rest_api";
|
|
else if (field === "createdByUserId") changed.createdByUserId = "another-owner";
|
|
else changed.config = { ...original.config, [field]: "different" };
|
|
const start = createOAuthAuthorizationStart(db, {
|
|
now: () => new Date(),
|
|
getConnectionRow: async () => {
|
|
await db.update(toolConnections).set(changed).where(eq(toolConnections.id, original.id));
|
|
return original;
|
|
},
|
|
setupPolicy: createConnectionSetupPolicy(db),
|
|
fixedPersonalIdentityForReconnect: createPersonalConnectionIdentity(db),
|
|
currentCloudConnector: () => null,
|
|
vercelConnect: null,
|
|
ensureOAuthClient: vi.fn(),
|
|
oauthEndpointsForConnection: vi.fn(),
|
|
assertOAuthEndpointUrl: vi.fn(),
|
|
});
|
|
await expect(start(company.id, original.id, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { gmail: "read" },
|
|
})).rejects.toMatchObject({ status: 409 });
|
|
expect((await service.getConnection(original.id)).config.workspaceSetup).toBeUndefined();
|
|
expect(await db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, original.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("binds selected Workspace service scopes to OAuth state and rejects other providers", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
|
oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" },
|
|
}, actor);
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { drive: "read" },
|
|
});
|
|
const url = new URL(started.authorizationUrl);
|
|
const expected = ["documents.readonly", "drive.readonly", "presentations.readonly", "spreadsheets.readonly"].map(value => `https://www.googleapis.com/auth/${value}`);
|
|
expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected);
|
|
const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!));
|
|
expect(state.requestedScopes).toEqual(expected);
|
|
await expect(service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: {},
|
|
})).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it.each(["https://www.googleapis.com/auth/documents.readonly", undefined, "", "https://www.googleapis.com/auth/gmail.send"])(
|
|
"requires explicit reviewed partial consent for customer Workspace OAuth (%s)", async (scope) => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-customer-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user",
|
|
oauthClient: { clientId: "test-workspace-client", clientSecret: "test-workspace-secret" },
|
|
}, actor);
|
|
const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback";
|
|
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION });
|
|
const authorization = new URL(started.authorizationUrl);
|
|
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION));
|
|
expect(authorization.searchParams.get("enable_granular_consent")).toBe("true");
|
|
const provider = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({
|
|
access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh", expires_in: 3600, token_type: "Bearer", scope,
|
|
});
|
|
expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1");
|
|
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [
|
|
{ name: "read_doc", annotations: { readOnlyHint: true } }, { name: "update_doc" },
|
|
] } });
|
|
});
|
|
const completing = service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: "fixture-code", redirectUri, actor });
|
|
if (scope?.endsWith("documents.readonly")) {
|
|
const completed = await completing;
|
|
expect(completed.connection.status).toBe("active");
|
|
expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
|
} else {
|
|
await expect(completing).rejects.toMatchObject({ details: { code: "oauth_scope_grant_invalid" } });
|
|
expect(provider).toHaveBeenCalledTimes(1);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["customer", "managed"] as const)(
|
|
"preserves an explicit empty agent selection through %s Workspace OAuth and reconnect", async (mode) => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-no-agents-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
|
const claim = connector.claim;
|
|
const scope = "https://www.googleapis.com/auth/documents.readonly";
|
|
connector.claim = vi.fn(async (input) => ({ ...await claim(input), scopes: [scope] }));
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const input = {
|
|
galleryKey: "google-workspace", grantKind: "user" as const,
|
|
connectionMethodKey: mode === "customer" ? "customer-workspace-oauth" : "paperclip-workspace",
|
|
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-secret" } } : {}),
|
|
};
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({
|
|
access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh",
|
|
expires_in: 3600, token_type: "Bearer", scope,
|
|
});
|
|
expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1");
|
|
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: {
|
|
tools: [{ name: "read_doc", annotations: { readOnlyHint: true } }],
|
|
} });
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, input, actor);
|
|
await service.putConnectionInstalls(connected.connectionId, { installs: [] }, actor);
|
|
const redirectUri = `https://paperclip.example/api/tools/oauth/${mode === "managed" ? "cloud-connector/" : ""}callback`;
|
|
for (const reconnect of [false, true]) {
|
|
if (reconnect) await service.connectGalleryApp(company.id, { ...input, reconnectConnectionId: connected.connectionId }, actor);
|
|
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor,
|
|
...(!reconnect ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
|
});
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
|
const completed = mode === "managed"
|
|
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "workspace-no-agents", actor })
|
|
: await service.completeOAuthCallback({ state, code: "workspace-no-agents", redirectUri, actor });
|
|
expect(completed.connection.status).toBe("active");
|
|
expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
|
expect(await service.listConnectionInstalls(connected.connectionId)).toEqual([]);
|
|
const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
expect(profile).toBeDefined();
|
|
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-chat",
|
|
connectionMethodKey: `customer-${capability}-oauth`,
|
|
name: "Chat scope fixture",
|
|
grantKind: "user",
|
|
oauthClient: { clientId: "google-chat-client", clientSecret: "google-chat-secret" },
|
|
}, actor);
|
|
const scopes = ["chat.spaces.readonly", "chat.messages.readonly", ...(capability === "write" ? ["chat.messages.create"] : [])]
|
|
.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
|
const removed = ["chat.memberships.readonly", "chat.users.readstate.readonly"]
|
|
.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
// A saved broad scope hint must not leak back into the next consent URL.
|
|
await db.update(toolConnections).set({ config: {
|
|
...connection.config,
|
|
oauth: { ...(connection.config.oauth as Record<string, unknown>), scopes: [...scopes, ...removed] },
|
|
} }).where(eq(toolConnections.id, connection.id));
|
|
const input = { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor };
|
|
const started = await service.startOAuth(company.id, connection.id, input);
|
|
expect(new URL(started.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(scopes);
|
|
for (const removedScope of removed) {
|
|
await expect(service.startOAuth(company.id, connection.id, { ...input, scopes: [...scopes, removedScope] }))
|
|
.rejects.toMatchObject({ status: 400, details: { code: "oauth_scope_widening_rejected", scopes: [removedScope] } });
|
|
}
|
|
});
|
|
|
|
it.each(["airtable", "beehiiv", "miro", "neon", "netlify", "sentry", "supabase", "todoist", "ticktick", "hugging-face"])(
|
|
"requests the reviewed read/write scopes for %s without adopting advertised admin scopes",
|
|
async (slug) => {
|
|
const company = await createCompany(db);
|
|
const actor = { actorType: "user" as const, actorId: "alice" };
|
|
const service = createTestToolAccessService(db, {
|
|
remoteHttpRequest: async (url, init) => {
|
|
const origin = new URL(url).origin;
|
|
if (init.method === "POST") return Response.json({ client_id: "fixture-client", ...JSON.parse(String(init.body)) });
|
|
return Response.json({ issuer: origin, authorization_endpoint: `${origin}/authorize`,
|
|
token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`,
|
|
scopes_supported: ["unrelated:admin"], response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
},
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: slug, connectionMethodKey: "mcp-oauth", grantKind: "user", ...(slug === "supabase" ? { configValues: { projectRef: "abcdefghijklmnopqrst" } } : {}) }, actor);
|
|
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor });
|
|
const review = JSON.parse(await fs.readFile(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8"))
|
|
.methods.find((entry: { app: string; method: string }) => entry.app === slug && entry.method === "mcp-oauth");
|
|
expect(new URL(started.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(review.requestedScopes);
|
|
expect(new URL(started.authorizationUrl).searchParams.get("scope")).not.toContain("unrelated:admin");
|
|
await expect(service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, scopes: ["unrelated:admin"],
|
|
})).rejects.toMatchObject({ details: { code: "oauth_scope_widening_rejected" } });
|
|
},
|
|
);
|
|
|
|
it("keeps tools outside a Google Workspace capability profile disabled", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("list_labels", "Lists Gmail labels."),
|
|
{
|
|
name: "create_label",
|
|
description: "Creates a Gmail label.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { name: { type: "string" } },
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]);
|
|
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "gmail",
|
|
connectionMethodKey: "customer-read-oauth",
|
|
name: "Personal read-only Gmail",
|
|
grantKind: "user",
|
|
oauthClient: {
|
|
clientId: "google-client",
|
|
clientSecret: "google-secret",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const refresh = await service.refreshCatalog(
|
|
connected.connectionId,
|
|
{ actorType: "user", actorId: "board" },
|
|
{
|
|
enableAllByDefault: true,
|
|
credentialHeaders: { Authorization: "Bearer google-access" },
|
|
},
|
|
);
|
|
const readEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "list_labels",
|
|
)!;
|
|
const blockedEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "create_label",
|
|
)!;
|
|
expect(readEntry.status).toBe("active");
|
|
expect(blockedEntry.status).toBe("disabled");
|
|
|
|
await expect(
|
|
service.finishGalleryAppConnection(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [readEntry.id, blockedEntry.id],
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
message: "Disabled actions cannot be enabled",
|
|
});
|
|
|
|
await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [readEntry.id],
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const [stillBlocked] = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.id, blockedEntry.id));
|
|
expect(stillBlocked!.status).toBe("disabled");
|
|
});
|
|
|
|
it("sends the MCP Streamable HTTP Accept header and decodes an SSE catalog response", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
// Emulate a spec-compliant Streamable HTTP server: 406 unless the request
|
|
// advertises `Accept: application/json, text/event-stream`, and an
|
|
// SSE-framed body in response. Regression guard for PAP-11096.
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (_url, init) => {
|
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
const accept = headers.accept ?? headers.Accept ?? "";
|
|
if (
|
|
!accept.includes("application/json") ||
|
|
!accept.includes("text/event-stream")
|
|
) {
|
|
return {
|
|
ok: false,
|
|
status: 406,
|
|
headers: { get: () => null },
|
|
text: async () =>
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32000,
|
|
message:
|
|
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
|
},
|
|
id: null,
|
|
}),
|
|
} as unknown as Response;
|
|
}
|
|
return mcpSseResponse(catalogResponse([
|
|
{
|
|
name: "kv_get",
|
|
description: "Read a value.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]));
|
|
});
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Streamable HTTP fixture",
|
|
transport: "mcp_remote",
|
|
config: { url: "http://127.0.0.1:8848/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
|
|
const refresh = await service.refreshCatalog(connection.id, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
|
|
expect(refresh.discoveredCount).toBe(1);
|
|
expect(refresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "kv_get", riskLevel: "read" }),
|
|
]),
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"http://127.0.0.1:8848/mcp",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
accept: "application/json, text/event-stream",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// The same probe backs the periodic health sweep, so it must also pass.
|
|
const health = await service.checkHealth(connection.id);
|
|
expect(health.connection.healthStatus).toBe("ok");
|
|
});
|
|
|
|
it.each(
|
|
[
|
|
{ sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
|
|
{
|
|
sourceTemplateKey: "unsupported-rest-fixture",
|
|
templateId: "paperclip.echo-calculator-time",
|
|
},
|
|
].flatMap((config) =>
|
|
(["checkHealth", "refreshCatalog"] as const).map((operation) => ({ config, operation })),
|
|
),
|
|
)("rejects unsupported REST tool connections without stdio validation: %j", async ({ config, operation }) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "REST regression fixture",
|
|
type: "rest_api",
|
|
});
|
|
const connection = await service.createConnection(company.id, {
|
|
applicationId: application.id,
|
|
name: "REST regression fixture",
|
|
transport: "rest_api",
|
|
config,
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const message = "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.";
|
|
|
|
await expect(service[operation](connection.id)).rejects.toMatchObject({
|
|
status: 422,
|
|
message,
|
|
details: { code: "tool_connection_transport_unsupported" },
|
|
});
|
|
const [saved] = await db.select().from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
expect(saved).toMatchObject({ healthStatus: "error", healthMessage: message });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(await service.listRuntimeSlots(company.id)).toEqual([]);
|
|
expect(await db.select().from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connection.id))).toEqual([]);
|
|
const audit = await db.select().from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.connectionId, connection.id));
|
|
expect(audit).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: operation === "checkHealth" ? "tool_connection.health_check" : "tool_connection.catalog_refresh",
|
|
outcome: "failure",
|
|
reasonCode: "tool_connection_transport_unsupported",
|
|
}),
|
|
]));
|
|
// Removing a method from the catalog must not strand its saved connections.
|
|
expect(await service.archiveConnection(connection.id)).toMatchObject({
|
|
connection: { status: "archived" },
|
|
});
|
|
});
|
|
|
|
it("rejects the obsolete Anthropic REST setup before storing credentials", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
await expect(service.connectGalleryApp(company.id, {
|
|
galleryKey: "anthropic",
|
|
connectionMethodKey: "api-key",
|
|
credentialValues: { "credentials.apiKey": "rest-regression-secret" },
|
|
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
|
|
status: 422,
|
|
message: "This app does not have an available connection method",
|
|
});
|
|
|
|
expect(await db.select().from(toolConnections)
|
|
.where(eq(toolConnections.companyId, company.id))).toEqual([]);
|
|
expect(await db.select().from(companySecrets)
|
|
.where(eq(companySecrets.companyId, company.id))).toEqual([]);
|
|
});
|
|
|
|
it("registers an approved local stdio template and exposes its runtime slot", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Local echo fixture",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const health = await service.checkHealth(connection.id);
|
|
const refresh = await service.refreshCatalog(connection.id);
|
|
const runtimeSlots = await service.listRuntimeSlots(company.id);
|
|
|
|
expect(health.runtimeSlot).toMatchObject({
|
|
connectionId: connection.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "stopped",
|
|
commandTemplateKey: "paperclip.echo-calculator-time",
|
|
});
|
|
expect(refresh.catalog.map((entry) => entry.toolName).sort()).toEqual([
|
|
"add",
|
|
"echo",
|
|
"fail_with_code",
|
|
"now",
|
|
]);
|
|
expect(runtimeSlots).toEqual([
|
|
expect.objectContaining({
|
|
connectionId: connection.id,
|
|
providerRef: "template:paperclip.echo-calculator-time",
|
|
healthStatus: "ok",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("requires tools:admin to create, list, and disable stdio command templates", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-admin-${randomUUID()}`;
|
|
const actor: Express.Request["actor"] = {
|
|
type: "board",
|
|
userId,
|
|
userName: "Tool Admin",
|
|
userEmail: null,
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
companyIds: [company.id],
|
|
memberships: [
|
|
{ companyId: company.id, membershipRole: "operator", status: "active" },
|
|
],
|
|
};
|
|
await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
const app = createRouteApp(db, actor);
|
|
|
|
await request(app)
|
|
.get(`/api/companies/${company.id}/tools/stdio-templates`)
|
|
.expect(403);
|
|
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:admin",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
});
|
|
|
|
const created = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/stdio-templates`)
|
|
.send({
|
|
templateId: "local.echo-admin",
|
|
name: "Local echo admin",
|
|
command: "node",
|
|
args: ["server.js"],
|
|
envKeys: ["ECHO_TOKEN"],
|
|
tools: [
|
|
{
|
|
name: "echo",
|
|
description: "Echo a message.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
})
|
|
.expect(201);
|
|
|
|
expect(created.body).toMatchObject({
|
|
templateId: "local.echo-admin",
|
|
status: "active",
|
|
source: "admin",
|
|
command: "node",
|
|
args: ["server.js"],
|
|
envKeys: ["ECHO_TOKEN"],
|
|
tools: [expect.objectContaining({ name: "echo" })],
|
|
});
|
|
|
|
const listed = await request(app)
|
|
.get(`/api/companies/${company.id}/tools/stdio-templates`)
|
|
.expect(200);
|
|
expect(listed.body.templates).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
templateId: "paperclip.echo-calculator-time",
|
|
source: "built_in",
|
|
}),
|
|
expect.objectContaining({
|
|
templateId: "local.echo-admin",
|
|
source: "admin",
|
|
status: "active",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const disabled = await request(app)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/stdio-templates/local.echo-admin/disable`,
|
|
)
|
|
.send({ reason: "no longer trusted" })
|
|
.expect(200);
|
|
|
|
expect(disabled.body).toMatchObject({
|
|
templateId: "local.echo-admin",
|
|
status: "disabled",
|
|
});
|
|
});
|
|
|
|
it("launches local stdio slots only through active admin-defined templates", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
await service.createStdioCommandTemplate(
|
|
company.id,
|
|
{
|
|
templateId: "admin.local-echo",
|
|
name: "Admin local echo",
|
|
command: "node",
|
|
args: ["./echo-mcp.js"],
|
|
envKeys: ["ADMIN_ECHO_TOKEN"],
|
|
tools: [
|
|
{
|
|
name: "echo",
|
|
description: "Echo a message.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Admin local echo",
|
|
transport: "local_stdio",
|
|
config: { templateId: "admin.local-echo" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const health = await service.checkHealth(connection.id);
|
|
const refresh = await service.refreshCatalog(connection.id);
|
|
|
|
expect(health.runtimeSlot).toMatchObject({
|
|
connectionId: connection.id,
|
|
runtimeKind: "local_stdio",
|
|
commandTemplateKey: "admin.local-echo",
|
|
});
|
|
expect(refresh.catalog).toEqual([
|
|
expect.objectContaining({
|
|
toolName: "echo",
|
|
status: "active",
|
|
riskLevel: "read",
|
|
}),
|
|
]);
|
|
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
name: "Rejected command config",
|
|
transport: "local_stdio",
|
|
config: { command: "node", args: ["./unapproved.js"] },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).rejects.toThrow(
|
|
"Local stdio MCP connections must use an approved templateId",
|
|
);
|
|
|
|
await service.disableStdioCommandTemplate(company.id, "admin.local-echo");
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
name: "Disabled admin template",
|
|
transport: "local_stdio",
|
|
config: { templateId: "admin.local-echo" },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).rejects.toThrow(
|
|
"Local stdio MCP connections must use an approved templateId",
|
|
);
|
|
});
|
|
|
|
it("reports persistence failure after a recognized MCP outage instead of suppressing the database error", async () => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id, applicationKey: `audit-failure-${randomUUID()}`,
|
|
name: "MCP audit fixture", type: "mcp_http", status: "active",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id, applicationId: application!.id, name: "MCP audit fixture",
|
|
uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false,
|
|
config: { url: "https://audit-failure.example.test/mcp" },
|
|
transportConfig: { url: "https://audit-failure.example.test/mcp" },
|
|
}).returning();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 }));
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
await db.execute(sql`CREATE FUNCTION test_mcp_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN RAISE EXCEPTION 'synthetic MCP audit write failure'; END $$`);
|
|
await db.execute(sql`CREATE TRIGGER test_mcp_audit_failure BEFORE INSERT ON tool_access_audit_events
|
|
FOR EACH ROW EXECUTE FUNCTION test_mcp_audit_failure()`);
|
|
try {
|
|
const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`);
|
|
expect(response.status).toBe(500);
|
|
expect(capture).toHaveBeenCalledOnce();
|
|
expect(capture.mock.calls[0]?.[0]).toMatchObject({ cause: expect.objectContaining({ message: "synthetic MCP audit write failure" }) });
|
|
} finally {
|
|
await db.execute(sql`DROP TRIGGER test_mcp_audit_failure ON tool_access_audit_events`);
|
|
await db.execute(sql`DROP FUNCTION test_mcp_audit_failure()`);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"local_trusted",
|
|
{
|
|
deploymentMode: "local_trusted" as const,
|
|
deploymentExposure: "private" as const,
|
|
},
|
|
],
|
|
[
|
|
"authenticated/private",
|
|
{
|
|
deploymentMode: "authenticated" as const,
|
|
deploymentExposure: "private" as const,
|
|
},
|
|
],
|
|
[
|
|
"authenticated/public",
|
|
{
|
|
deploymentMode: "authenticated" as const,
|
|
deploymentExposure: "public" as const,
|
|
},
|
|
],
|
|
])(
|
|
"always blocks link-local remote HTTP endpoints in %s before fetch",
|
|
async (_label, deployment) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, deployment);
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(new Error("fetch should not be called"));
|
|
|
|
try {
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
name: "Metadata endpoint",
|
|
transport: "mcp_remote",
|
|
config: { url: "http://169.254.169.254/latest/meta-data" },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
details: { code: "remote_http_private_endpoint" },
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("creates profiles with entries, binds them to agents, and resolves effective allowed tools", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Profile Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Profile Fixture ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: `Profile Connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
transportConfig: { url: "https://fixture.example/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
})
|
|
.returning();
|
|
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Email tools",
|
|
defaultAction: "deny",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
],
|
|
});
|
|
const added = await service.addProfileEntry(profile.id, {
|
|
selectorType: "risk_level",
|
|
effect: "exclude",
|
|
riskLevel: "destructive",
|
|
});
|
|
await expect(
|
|
service.updateProfileEntry(added.id, { effect: "include" }),
|
|
).resolves.toMatchObject({
|
|
effect: "include",
|
|
riskLevel: "destructive",
|
|
});
|
|
await expect(service.deleteProfileEntry(added.id)).resolves.toMatchObject({
|
|
id: added.id,
|
|
});
|
|
await service.updateProfile(profile.id, {
|
|
entries: [
|
|
{
|
|
selectorType: "connection",
|
|
effect: "include",
|
|
connectionId: connection.id,
|
|
},
|
|
],
|
|
});
|
|
await service.bindProfile(
|
|
profile.id,
|
|
{ targetType: "agent", targetId: agent.id, priority: 25 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const listed = await service.listProfiles(company.id);
|
|
const effective = await service.getEffectiveProfilesForAgent(
|
|
company.id,
|
|
agent.id,
|
|
);
|
|
|
|
expect(listed).toEqual([
|
|
expect.objectContaining({
|
|
id: profile.id,
|
|
entries: [
|
|
expect.objectContaining({
|
|
selectorType: "connection",
|
|
connectionId: connection.id,
|
|
}),
|
|
],
|
|
bindings: [
|
|
expect.objectContaining({
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
priority: 25,
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
expect(effective).toMatchObject({
|
|
agentId: agent.id,
|
|
allowedToolNames: ["send_email"],
|
|
allowedTools: [
|
|
expect.objectContaining({
|
|
id: catalogEntry.id,
|
|
toolName: "send_email",
|
|
}),
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
service.unbindProfile(profile.id, {
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
}),
|
|
).resolves.toEqual({ unbound: 1 });
|
|
await expect(
|
|
service.getEffectiveProfilesForAgent(company.id, agent.id),
|
|
).resolves.toMatchObject({
|
|
profiles: [],
|
|
allowedToolNames: [],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"tokenBroker.tokenUrl",
|
|
{
|
|
tokenBroker: {
|
|
enabled: true,
|
|
tokenUrl: "http://169.254.169.254/token",
|
|
},
|
|
},
|
|
],
|
|
[
|
|
"tokenBroker.exchangeTokenUrl",
|
|
{
|
|
tokenBroker: {
|
|
enabled: true,
|
|
exchangeTokenUrl: "http://169.254.169.254/token",
|
|
},
|
|
},
|
|
],
|
|
["tokenExchangeUrl", { tokenExchangeUrl: "http://169.254.169.254/token" }],
|
|
[
|
|
"pagesTokenExchangeUrl",
|
|
{ pagesTokenExchangeUrl: "http://169.254.169.254/token" },
|
|
],
|
|
])(
|
|
"rejects a link-local %s when a remote connection is created",
|
|
async (_field, brokerConfig) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
});
|
|
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
name: `Rejected broker ${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
config: { url: "https://93.184.216.34/mcp", ...brokerConfig },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
details: { code: "remote_http_private_endpoint" },
|
|
});
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it("rejects a link-local token broker when a remote connection is updated", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
});
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Initially safe broker",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://93.184.216.34/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
|
|
await expect(
|
|
service.updateConnection(connection.id, {
|
|
config: {
|
|
...connection.config,
|
|
tokenBroker: {
|
|
enabled: true,
|
|
tokenUrl: "http://169.254.169.254/token",
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
details: { code: "remote_http_private_endpoint" },
|
|
});
|
|
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
|
|
config: { url: "https://93.184.216.34/mcp" },
|
|
});
|
|
});
|
|
|
|
it("implicitly allowlists the configured Pages API host for internal token brokers", async () => {
|
|
vi.stubEnv("PAPERCLIP_PAGES_API_URL", "http://127.0.0.1:8787");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
});
|
|
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
name: "Internal Pages broker",
|
|
transport: "mcp_remote",
|
|
config: {
|
|
url: "https://93.184.216.34/mcp",
|
|
tokenBroker: {
|
|
enabled: true,
|
|
tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange",
|
|
},
|
|
},
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
config: {
|
|
tokenBroker: { tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("lists testable agents without calculating every agent's access summary", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const actor = boardSessionActor(company.id, "operator", userId);
|
|
const agent = await createAgent(db, company.id);
|
|
await createAgent(db, company.id, "terminated");
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow test connection ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
|
|
const app = createRouteApp(
|
|
db,
|
|
actor,
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
const res = await request(app)
|
|
.get(`/api/tool-connections/${connection.id}/test-agents`)
|
|
.expect(200);
|
|
|
|
expect(res.body.agents).toHaveLength(1);
|
|
expect(res.body.agents[0]).toMatchObject({
|
|
id: agent.id,
|
|
orgDepth: 0,
|
|
});
|
|
expect(res.body.agents[0]).not.toHaveProperty("effectiveAccess");
|
|
|
|
const accessRes = await request(app)
|
|
.get(
|
|
`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`,
|
|
)
|
|
.expect(200);
|
|
expect(accessRes.body.access).toMatchObject({
|
|
connectionId: connection.id,
|
|
toolCount: 1,
|
|
allowedCount: 1,
|
|
askFirstCount: 0,
|
|
offCount: 0,
|
|
});
|
|
});
|
|
|
|
it.each(["local_implicit", "session"] as const)("excludes unassignable agents from tests even for a %s instance admin", async (source) => {
|
|
const company = await createCompany(db);
|
|
const userId = `admin-tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const active = await createAgent(db, company.id);
|
|
const terminated = await createAgent(db, company.id, "terminated");
|
|
const pending = await createAgent(db, company.id, "pending_approval");
|
|
const otherCompany = await createCompany(db);
|
|
const foreign = await createAgent(db, otherCompany.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret" });
|
|
const execute = vi.spyOn(gateway, "executeTestCall");
|
|
const summarize = vi.spyOn(gateway, "summarizeConnectionAccessForAgent");
|
|
const app = createRouteApp(db, {
|
|
...boardSessionActor(company.id, "operator", userId),
|
|
isInstanceAdmin: true, source,
|
|
}, gateway);
|
|
|
|
const res = await request(app).get(`/api/tool-connections/${connection.id}/test-agents`).expect(200);
|
|
expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([active.id]);
|
|
// A stale picker or a direct request must not bypass the same lifecycle guard.
|
|
for (const agent of [terminated, pending, foreign]) {
|
|
await request(app).get(`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`).expect(403);
|
|
await request(app).post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({ agentId: agent.id, toolName: "send_email", parameters: { to: "fixture@example.com" } }).expect(403);
|
|
}
|
|
expect(execute).not.toHaveBeenCalled();
|
|
expect(summarize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists only writable agents and ranks the highest accessible agent first", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `scoped-tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"], "viewer");
|
|
const actor = boardSessionActor(company.id, "viewer", userId);
|
|
const root = await createAgent(db, company.id);
|
|
const [accessibleManager] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "Accessible manager",
|
|
role: "manager",
|
|
reportsTo: root.id,
|
|
status: "active",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [accessibleReport] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "Accessible report",
|
|
role: "engineer",
|
|
reportsTo: accessibleManager!.id,
|
|
status: "active",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: { agentIds: [accessibleManager!.id, accessibleReport!.id] },
|
|
grantedByUserId: "owner",
|
|
});
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const app = createRouteApp(
|
|
db,
|
|
actor,
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.get(`/api/tool-connections/${connection.id}/test-agents`)
|
|
.expect(200);
|
|
|
|
expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([
|
|
accessibleManager!.id,
|
|
accessibleReport!.id,
|
|
]);
|
|
expect(
|
|
res.body.agents.map((agent: { orgDepth: number }) => agent.orgDepth),
|
|
).toEqual([1, 2]);
|
|
expect(res.body.agents).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: root.id })]),
|
|
);
|
|
});
|
|
|
|
it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const actor = boardSessionActor(company.id, "operator", userId);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow with author ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
createdByAgentId: agent.id,
|
|
});
|
|
|
|
const app = createRouteApp(
|
|
db,
|
|
actor,
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
const res = await request(app)
|
|
.get(
|
|
`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`,
|
|
)
|
|
.expect(200);
|
|
|
|
const summary = res.body.access;
|
|
expect(typeof summary.lastChangedAt).toBe("string");
|
|
expect(summary.lastChangedByAgentId).toBe(agent.id);
|
|
expect(summary.lastChangedByName).toBe(agent.name);
|
|
});
|
|
|
|
it("executes allowed test calls as a board user while attributing the selected agent", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow test call ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) =>
|
|
mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: JSON.parse(String(init?.body)).id,
|
|
result: { content: [{ type: "text", text: "sent" }] },
|
|
}),
|
|
);
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com", body: "hi" },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body).toMatchObject({
|
|
decision: "allowed",
|
|
result: {
|
|
data: expect.objectContaining({
|
|
isError: false,
|
|
transport: "mcp_http",
|
|
}),
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation).toMatchObject({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
agentId: agent.id,
|
|
runId: null,
|
|
status: "succeeded",
|
|
});
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.companyId, company.id));
|
|
expect(audits).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "call_completed",
|
|
details: expect.objectContaining({
|
|
source: "test",
|
|
agentId: agent.id,
|
|
runId: null,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("turns ask-first test calls into real pending action requests", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Ask first ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body).toMatchObject({
|
|
decision: "ask_first",
|
|
actionRequestId: expect.any(String),
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.id, res.body.actionRequestId));
|
|
expect(actionRequest).toMatchObject({
|
|
companyId: company.id,
|
|
issueId: null,
|
|
status: "pending",
|
|
requestedByUserId: userId,
|
|
requestedByAgentId: null,
|
|
});
|
|
expect(actionRequest!.signedArguments).toBeTruthy();
|
|
const events = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(eq(toolCallEvents.companyId, company.id));
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
eventType: "approval_requested",
|
|
actionRequestId: actionRequest!.id,
|
|
metadata: expect.objectContaining({ source: "test" }),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const gatewayAudit = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, company.id),
|
|
eq(activityLog.action, "tool_gateway.approval_requested"),
|
|
),
|
|
);
|
|
expect(gatewayAudit).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
agentId: agent.id,
|
|
details: expect.objectContaining({
|
|
source: "test",
|
|
actionRequestId: res.body.actionRequestId,
|
|
invocationId: res.body.invocationId,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const dedicatedAudit = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.companyId, company.id));
|
|
expect(dedicatedAudit).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
details: expect.objectContaining({
|
|
source: "test",
|
|
agentId: agent.id,
|
|
actionRequestId: res.body.actionRequestId,
|
|
runId: null,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("cancels an ask-first test request when approval signing is unavailable", async ({ onTestFinished }) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Ask first without signing ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: " " }),
|
|
);
|
|
const keyDirectory = await fs.mkdtemp(join(tmpdir(), "tool-access-signing-failure-"));
|
|
onTestFinished(() => fs.rm(keyDirectory, { recursive: true, force: true }));
|
|
const keyFile = join(keyDirectory, "master.key");
|
|
await fs.writeFile(keyFile, "corrupt-fixture-master-key", { mode: 0o600 });
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "");
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(500);
|
|
|
|
expect(res.body).toMatchObject({
|
|
reasonCode: "signing_secret_unconfigured",
|
|
error: expect.stringContaining("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"),
|
|
});
|
|
const [actionRequest] = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(actionRequest).toMatchObject({
|
|
status: "cancelled",
|
|
signedArguments: null,
|
|
});
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation).toMatchObject({
|
|
status: "failed",
|
|
errorCode: "signing_secret_unconfigured",
|
|
});
|
|
});
|
|
|
|
|
|
it("drives an ask-first test call through its live lifecycle (waiting → approved/done with the real result)", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Ask first ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const gateway = createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
gateway,
|
|
);
|
|
|
|
// 1. Park the call as a pending action request.
|
|
const created = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com", body: "hi" },
|
|
})
|
|
.expect(200);
|
|
const actionRequestId = created.body.actionRequestId as string;
|
|
expect(actionRequestId).toEqual(expect.any(String));
|
|
|
|
await request(app)
|
|
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`)
|
|
.expect(404);
|
|
|
|
// 2. Status starts as "waiting" and surfaces the redacted "Where" snapshot.
|
|
const waiting = await request(app)
|
|
.get(
|
|
`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`,
|
|
)
|
|
.expect(200);
|
|
expect(waiting.body).toMatchObject({ actionRequestId, phase: "waiting" });
|
|
expect(waiting.body.parameters).toHaveProperty("to");
|
|
expect(waiting.body.result).toBeUndefined();
|
|
|
|
// 3. Approving from the review queue is what runs the parked test call.
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) =>
|
|
mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: JSON.parse(String(init?.body)).id,
|
|
result: { content: [{ type: "text", text: "sent" }] },
|
|
}),
|
|
);
|
|
await gateway.approveActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId,
|
|
actor: { userId },
|
|
});
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
|
|
// 4. Status mutates into the completed result shape with the real response.
|
|
const done = await request(app)
|
|
.get(
|
|
`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`,
|
|
)
|
|
.expect(200);
|
|
expect(done.body.phase).toBe("done");
|
|
expect(done.body.error).toBeUndefined();
|
|
expect(done.body.result).toBeDefined();
|
|
expect(typeof done.body.durationMs).toBe("number");
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation).toMatchObject({
|
|
status: "succeeded",
|
|
approvalState: "approved",
|
|
});
|
|
});
|
|
|
|
it("creates a fresh ask-first request when the Test tab reruns the same side-effecting action", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Ask first ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const gateway = createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
gateway,
|
|
);
|
|
const body = {
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com", body: "hi" },
|
|
};
|
|
|
|
const first = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send(body)
|
|
.expect(200);
|
|
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) =>
|
|
mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: JSON.parse(String(init?.body)).id,
|
|
result: { content: [{ type: "text", text: "sent" }] },
|
|
}),
|
|
);
|
|
await gateway.approveActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId: first.body.actionRequestId as string,
|
|
actor: { userId },
|
|
});
|
|
|
|
const second = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send(body)
|
|
.expect(200);
|
|
|
|
expect(second.body).toMatchObject({
|
|
decision: "ask_first",
|
|
actionRequestId: expect.any(String),
|
|
});
|
|
expect(second.body.actionRequestId).not.toBe(first.body.actionRequestId);
|
|
|
|
const requests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(eq(toolActionRequests.companyId, company.id));
|
|
expect(requests).toHaveLength(2);
|
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
expect(requests.map((row) => row.status).sort()).toEqual([
|
|
"executed",
|
|
"pending",
|
|
]);
|
|
});
|
|
|
|
it("reports a denied ask-first test call as denied without running the tool", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Ask first ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const gateway = createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
gateway,
|
|
);
|
|
|
|
const created = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(200);
|
|
const actionRequestId = created.body.actionRequestId as string;
|
|
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
await gateway.declineActionRequest({
|
|
companyId: company.id,
|
|
actionRequestId,
|
|
actor: { userId },
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
const denied = await request(app)
|
|
.get(
|
|
`/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`,
|
|
)
|
|
.expect(200);
|
|
expect(denied.body.phase).toBe("denied");
|
|
expect(denied.body.result).toBeUndefined();
|
|
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation).toMatchObject({
|
|
status: "denied",
|
|
approvalState: "rejected",
|
|
});
|
|
});
|
|
|
|
|
|
it("returns off for blocked test calls without executing the remote tool", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Block ${randomUUID()}`,
|
|
policyType: "block",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(res.body).toMatchObject({
|
|
decision: "off",
|
|
error: { reasonCode: "deny_policy_block" },
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [invocation] = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id));
|
|
expect(invocation).toMatchObject({
|
|
status: "denied",
|
|
errorCode: "deny_policy_block",
|
|
actorType: "user",
|
|
actorId: userId,
|
|
agentId: agent.id,
|
|
runId: null,
|
|
});
|
|
|
|
const gatewayAudit = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, company.id),
|
|
eq(activityLog.action, "tool_gateway.call_denied"),
|
|
),
|
|
);
|
|
expect(gatewayAudit).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
agentId: agent.id,
|
|
details: expect.objectContaining({
|
|
source: "test",
|
|
invocationId: res.body.invocationId,
|
|
reasonCode: "deny_policy_block",
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const dedicatedAudit = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.companyId, company.id));
|
|
expect(dedicatedAudit).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "call_denied",
|
|
reasonCode: "deny_policy_block",
|
|
details: expect.objectContaining({
|
|
source: "test",
|
|
agentId: agent.id,
|
|
runId: null,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
|
|
it("denies test calls through agents the board user cannot task", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const unassignableAgent = await createAgent(db, company.id, "terminated");
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow denied impersonation fixture ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: unassignableAgent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(403);
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, company.id)),
|
|
).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("does not bypass quarantined catalog entries during test calls", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id, {
|
|
quarantined: true,
|
|
});
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow quarantined fixture ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(404);
|
|
|
|
expect(res.body).toMatchObject({ reasonCode: "tool_not_found" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("summarizes profile index counts and restores archived profiles through update", async () => {
|
|
const company = await createCompany(db);
|
|
const [agentOne, agentTwo] = await db
|
|
.insert(agents)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
name: `Profile Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Profile Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
},
|
|
])
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `summary-app-${randomUUID()}`,
|
|
name: "Summary app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Summary connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
})
|
|
.returning();
|
|
const [readEntry, writeEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
name: "read_notes",
|
|
toolName: "read_notes",
|
|
riskLevel: "read",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
},
|
|
])
|
|
.returning();
|
|
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "All except write tools",
|
|
defaultAction: "allow",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "exclude",
|
|
toolName: "send_email",
|
|
},
|
|
],
|
|
});
|
|
await service.bindProfile(
|
|
profile.id,
|
|
{ targetType: "company", targetId: company.id },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const [listed] = await service.listProfiles(company.id);
|
|
expect(listed).toMatchObject({
|
|
id: profile.id,
|
|
status: "active",
|
|
summary: {
|
|
accessMode: "all_except",
|
|
allowedToolCount: 1,
|
|
allowedApplicationCount: 1,
|
|
excludedToolCount: 1,
|
|
totalToolCount: 2,
|
|
assignmentCount: 1,
|
|
appliesToAgentCount: 2,
|
|
isCompanyDefault: true,
|
|
},
|
|
});
|
|
await expect(
|
|
service.getEffectiveProfilesForAgent(company.id, agentOne!.id),
|
|
).resolves.toMatchObject({
|
|
allowedTools: [
|
|
expect.objectContaining({ id: readEntry!.id, toolName: "read_notes" }),
|
|
],
|
|
allowedToolNames: ["read_notes"],
|
|
});
|
|
|
|
const archived = await service.updateProfile(profile.id, {
|
|
status: "archived",
|
|
});
|
|
expect(archived.status).toBe("archived");
|
|
await expect(
|
|
service.getEffectiveProfilesForAgent(company.id, agentTwo!.id),
|
|
).resolves.toMatchObject({
|
|
profiles: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
});
|
|
|
|
const restored = await service.updateProfile(profile.id, {
|
|
status: "active",
|
|
});
|
|
expect(restored.status).toBe("active");
|
|
await expect(
|
|
service.getEffectiveProfilesForAgent(company.id, agentTwo!.id),
|
|
).resolves.toMatchObject({
|
|
allowedTools: [expect.objectContaining({ id: readEntry!.id })],
|
|
allowedToolNames: ["read_notes"],
|
|
});
|
|
expect(writeEntry).toBeDefined();
|
|
});
|
|
|
|
it("shows only the narrowest matching tier in effective agent previews", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Scoped Preview Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `preview-app-${randomUUID()}`,
|
|
name: "Preview app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Preview connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
})
|
|
.returning();
|
|
await db.insert(toolCatalogEntries).values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
});
|
|
|
|
const service = createTestToolAccessService(db);
|
|
const [companyProfile, agentProfile] = await Promise.all([
|
|
service.createProfile(company.id, {
|
|
profileKey: `company-default-${randomUUID()}`,
|
|
name: "Company default",
|
|
defaultAction: "deny",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
],
|
|
}),
|
|
service.createProfile(company.id, {
|
|
profileKey: `agent-override-${randomUUID()}`,
|
|
name: "Agent override",
|
|
defaultAction: "deny",
|
|
}),
|
|
]);
|
|
await service.bindProfile(
|
|
companyProfile.id,
|
|
{ targetType: "company", targetId: company.id, priority: 100 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await service.bindProfile(
|
|
agentProfile.id,
|
|
{ targetType: "agent", targetId: agent!.id, priority: 10 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const effective = await service.getEffectiveProfilesForAgent(
|
|
company.id,
|
|
agent!.id,
|
|
);
|
|
|
|
expect(effective.profiles.map((profile) => profile.id)).toEqual([
|
|
agentProfile.id,
|
|
]);
|
|
expect(
|
|
effective.bindings.map(
|
|
(binding) => `${binding.targetType}:${binding.targetId}`,
|
|
),
|
|
).toEqual([`agent:${agent!.id}`]);
|
|
expect(effective.allowedTools).toEqual([]);
|
|
expect(effective.allowedToolNames).toEqual([]);
|
|
});
|
|
|
|
it("prefers agent-scoped allows over broader company defaults in previews", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Scoped Allow Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `allow-app-${randomUUID()}`,
|
|
name: "Allow app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Allow connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
})
|
|
.returning();
|
|
await db.insert(toolCatalogEntries).values([
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
name: "read_notes",
|
|
toolName: "read_notes",
|
|
riskLevel: "read",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
connectionId: connection!.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
status: "active",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
},
|
|
]);
|
|
|
|
const service = createTestToolAccessService(db);
|
|
const [companyProfile, agentProfile] = await Promise.all([
|
|
service.createProfile(company.id, {
|
|
profileKey: `company-read-${randomUUID()}`,
|
|
name: "Company read",
|
|
defaultAction: "deny",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "read_notes",
|
|
},
|
|
],
|
|
}),
|
|
service.createProfile(company.id, {
|
|
profileKey: `agent-write-${randomUUID()}`,
|
|
name: "Agent write",
|
|
defaultAction: "deny",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
await service.bindProfile(
|
|
companyProfile.id,
|
|
{ targetType: "company", targetId: company.id, priority: 100 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await service.bindProfile(
|
|
agentProfile.id,
|
|
{ targetType: "agent", targetId: agent!.id, priority: 10 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const effective = await service.getEffectiveProfilesForAgent(
|
|
company.id,
|
|
agent!.id,
|
|
);
|
|
|
|
expect(effective.profiles.map((profile) => profile.id)).toEqual([
|
|
agentProfile.id,
|
|
]);
|
|
expect(effective.allowedToolNames).toEqual(["send_email"]);
|
|
});
|
|
|
|
it("duplicates profiles with entries and optional assignments", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Duplicate Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Email tools source",
|
|
defaultAction: "allow",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "exclude",
|
|
toolName: "delete_email",
|
|
},
|
|
],
|
|
});
|
|
await service.bindProfile(
|
|
profile.id,
|
|
{ targetType: "agent", targetId: agent!.id, priority: 25 },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const unassignedCopy = await service.duplicateProfile(profile.id, {
|
|
name: "Email tools unassigned copy",
|
|
includeAssignments: false,
|
|
});
|
|
expect(unassignedCopy).toMatchObject({
|
|
name: "Email tools unassigned copy",
|
|
status: "active",
|
|
defaultAction: "allow",
|
|
entries: [
|
|
expect.objectContaining({
|
|
selectorType: "tool_name",
|
|
effect: "exclude",
|
|
toolName: "delete_email",
|
|
}),
|
|
],
|
|
bindings: [],
|
|
summary: expect.objectContaining({ assignmentCount: 0 }),
|
|
});
|
|
|
|
const assignedCopy = await service.duplicateProfile(profile.id, {
|
|
name: "Email tools assigned copy",
|
|
includeAssignments: true,
|
|
});
|
|
expect(assignedCopy).toMatchObject({
|
|
name: "Email tools assigned copy",
|
|
status: "active",
|
|
bindings: [
|
|
expect.objectContaining({
|
|
targetType: "agent",
|
|
targetId: agent!.id,
|
|
priority: 25,
|
|
}),
|
|
],
|
|
summary: expect.objectContaining({
|
|
assignmentCount: 1,
|
|
appliesToAgentCount: 1,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("deletes profiles with cascades and guards company defaults", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Delete Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `profile-${randomUUID()}`,
|
|
name: "Delete source",
|
|
entries: [
|
|
{
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "send_email",
|
|
},
|
|
],
|
|
});
|
|
await service.bindProfile(
|
|
profile.id,
|
|
{ targetType: "agent", targetId: agent!.id },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const deleted = await service.deleteProfile(profile.id, { force: false });
|
|
expect(deleted).toMatchObject({
|
|
profile: expect.objectContaining({ id: profile.id }),
|
|
summary: expect.objectContaining({
|
|
assignmentCount: 1,
|
|
appliesToAgentCount: 1,
|
|
}),
|
|
reassignedToProfileId: null,
|
|
});
|
|
await expect(service.getProfile(profile.id)).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profile.id)),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, profile.id)),
|
|
).resolves.toEqual([]);
|
|
|
|
const defaultProfile = await service.createProfile(company.id, {
|
|
profileKey: `default-profile-${randomUUID()}`,
|
|
name: "Company default delete guard",
|
|
defaultAction: "allow",
|
|
});
|
|
await service.bindProfile(
|
|
defaultProfile.id,
|
|
{ targetType: "company", targetId: company.id },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await expect(
|
|
service.deleteProfile(defaultProfile.id, { force: false }),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: {
|
|
summary: expect.objectContaining({
|
|
isCompanyDefault: true,
|
|
assignmentCount: 1,
|
|
appliesToAgentCount: 1,
|
|
}),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.deleteProfile(defaultProfile.id, { force: true }),
|
|
).resolves.toMatchObject({
|
|
profile: expect.objectContaining({ id: defaultProfile.id }),
|
|
summary: expect.objectContaining({ isCompanyDefault: true }),
|
|
});
|
|
});
|
|
|
|
it("keeps duplicate, delete, and new-tools profile routes permission-gated and viewer-safe", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Route Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `route-profile-${randomUUID()}`,
|
|
name: "Route profile",
|
|
defaultAction: "deny",
|
|
});
|
|
|
|
const agentApp = createRouteApp(db, {
|
|
type: "agent",
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
runId: null,
|
|
source: "agent_jwt",
|
|
});
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer"),
|
|
);
|
|
|
|
const viewerRead = await request(viewerApp).get(
|
|
`/api/tool-profiles/${profile.id}/new-tools`,
|
|
);
|
|
expect(viewerRead.status).toBe(200);
|
|
expect(viewerRead.body).toMatchObject({
|
|
profileId: profile.id,
|
|
pendingCount: 0,
|
|
tools: [],
|
|
});
|
|
|
|
await request(agentApp)
|
|
.get(`/api/tool-profiles/${profile.id}/new-tools`)
|
|
.expect(403);
|
|
await request(agentApp)
|
|
.post(`/api/tool-profiles/${profile.id}/duplicate`)
|
|
.send({ name: "Agent copy", includeAssignments: true })
|
|
.expect(403);
|
|
await request(agentApp)
|
|
.delete(`/api/tool-profiles/${profile.id}`)
|
|
.send({ force: false })
|
|
.expect(403);
|
|
await request(agentApp)
|
|
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
|
|
.send({
|
|
decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }],
|
|
})
|
|
.expect(403);
|
|
|
|
await request(viewerApp)
|
|
.post(`/api/tool-profiles/${profile.id}/duplicate`)
|
|
.send({ name: "Viewer copy", includeAssignments: true })
|
|
.expect(403);
|
|
await request(viewerApp)
|
|
.delete(`/api/tool-profiles/${profile.id}`)
|
|
.send({ force: false })
|
|
.expect(403);
|
|
await request(viewerApp)
|
|
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
|
|
.send({
|
|
decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }],
|
|
})
|
|
.expect(403);
|
|
});
|
|
|
|
it("returns 404 for cross-company profile routes and missing profiles", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(otherCompany.id, {
|
|
profileKey: `other-profile-${randomUUID()}`,
|
|
name: "Other company profile",
|
|
defaultAction: "deny",
|
|
});
|
|
const entry = await service.addProfileEntry(profile.id, {
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "read_notes",
|
|
});
|
|
const app = createRouteApp(db, memberActor(allowedCompany, false));
|
|
|
|
const crossTenantResponses = [
|
|
await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`),
|
|
await request(app)
|
|
.patch(`/api/tool-profiles/${profile.id}`)
|
|
.send({ name: "Cross-tenant edit" }),
|
|
await request(app)
|
|
.post(`/api/tool-profiles/${profile.id}/duplicate`)
|
|
.send({ name: "Copy" }),
|
|
await request(app).delete(`/api/tool-profiles/${profile.id}`).send({}),
|
|
await request(app)
|
|
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
|
|
.send({
|
|
decisions: [
|
|
{ catalogEntryId: randomUUID(), decision: "keep_blocked" },
|
|
],
|
|
}),
|
|
await request(app)
|
|
.post(`/api/tool-profiles/${profile.id}/entries`)
|
|
.send({
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "write_notes",
|
|
}),
|
|
await request(app)
|
|
.patch(`/api/tool-profile-entries/${entry.id}`)
|
|
.send({ effect: "exclude" }),
|
|
await request(app).delete(`/api/tool-profile-entries/${entry.id}`),
|
|
];
|
|
const missingRes = await request(app).get(
|
|
`/api/tool-profiles/${randomUUID()}/new-tools`,
|
|
);
|
|
|
|
expect(crossTenantResponses.map((response) => response.status)).toEqual(
|
|
crossTenantResponses.map(() => 404),
|
|
);
|
|
expect(missingRes.status).toBe(404);
|
|
expect(crossTenantResponses[0]!.body).toEqual(missingRes.body);
|
|
});
|
|
|
|
it("returns 404 for cross-company connection routes, including instance admins", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connection = await service.createConnection(otherCompany.id, {
|
|
name: "Other company connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://other-company.example/mcp" },
|
|
});
|
|
const oauthState = randomUUID();
|
|
await db.insert(toolOauthStates).values({
|
|
state: oauthState,
|
|
companyId: otherCompany.id,
|
|
connectionId: connection.id,
|
|
codeVerifier: "cross-tenant-code-verifier",
|
|
createdByActorType: "user",
|
|
createdByActorId: "other-user",
|
|
createdBySessionId: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
const toolGateway = {} as ToolGatewayService;
|
|
const app = createRouteApp(
|
|
db,
|
|
memberActor(allowedCompany, true),
|
|
toolGateway,
|
|
);
|
|
const foreignOAuthRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state: oauthState, code: "oauth-code" });
|
|
const missingOAuthRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state: randomUUID(), code: "oauth-code" });
|
|
|
|
const crossTenantResponses = [
|
|
await request(app).post(`/api/tools/oauth/${connection.id}/start`),
|
|
await request(app).get(`/api/tool-connections/${connection.id}`),
|
|
await request(app).get(`/api/tool-connections/${connection.id}/grants`),
|
|
await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/grants/installations`)
|
|
.send({}),
|
|
await request(app).delete(
|
|
`/api/tool-connections/${connection.id}/grants/${randomUUID()}`,
|
|
),
|
|
await request(app).get(`/api/tool-connections/${connection.id}/usage`),
|
|
await request(app).get(`/api/tool-connections/${connection.id}/installs`),
|
|
await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [] }),
|
|
await request(app).get(
|
|
`/api/tool-connections/${connection.id}/test-agents`,
|
|
),
|
|
await request(app).get(
|
|
`/api/tool-connections/${connection.id}/test-agents/${randomUUID()}/access`,
|
|
),
|
|
await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: randomUUID(),
|
|
toolName: "read_notes",
|
|
parameters: {},
|
|
}),
|
|
await request(app).get(
|
|
`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`,
|
|
),
|
|
await request(app)
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ name: "Cross-tenant edit" }),
|
|
await request(app).delete(`/api/tool-connections/${connection.id}`),
|
|
await request(app).post(
|
|
`/api/tool-connections/${connection.id}/health-check`,
|
|
),
|
|
await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/reconnect`)
|
|
.send({ credentialValues: {} }),
|
|
await request(app).post(
|
|
`/api/tool-connections/${connection.id}/catalog/refresh`,
|
|
),
|
|
await request(app).get(`/api/tool-connections/${connection.id}/catalog`),
|
|
await request(app).get(
|
|
`/api/tool-connections/${connection.id}/activity?limit=5`,
|
|
),
|
|
];
|
|
expect(foreignOAuthRes.status).toBe(400);
|
|
expect(missingOAuthRes.status).toBe(400);
|
|
expect(foreignOAuthRes.body).toEqual(missingOAuthRes.body);
|
|
const missingRes = await request(app).get(
|
|
`/api/tool-connections/${randomUUID()}`,
|
|
);
|
|
|
|
for (const response of crossTenantResponses) {
|
|
expect(response.status).toBe(404);
|
|
}
|
|
expect(missingRes.status).toBe(404);
|
|
expect(crossTenantResponses[1]!.body).toEqual(missingRes.body);
|
|
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
|
|
id: connection.id,
|
|
});
|
|
});
|
|
|
|
it("installs the safe example fixture idempotently and smokes allow, deny, and audit paths", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
const before = await service.listExamples(company.id);
|
|
expect(before).toEqual([
|
|
expect.objectContaining({
|
|
id: "safe-read-only-todo-kv",
|
|
install: expect.objectContaining({
|
|
installed: false,
|
|
canInstall: true,
|
|
}),
|
|
}),
|
|
]);
|
|
|
|
const install = await service.installExample(
|
|
company.id,
|
|
"safe-read-only-todo-kv",
|
|
{
|
|
actorType: "user",
|
|
actorId: "board",
|
|
},
|
|
);
|
|
const secondInstall = await service.installExample(
|
|
company.id,
|
|
"safe-read-only-todo-kv",
|
|
{
|
|
actorType: "user",
|
|
actorId: "board",
|
|
},
|
|
);
|
|
|
|
expect(install.created).toBe(true);
|
|
expect(secondInstall.created).toBe(false);
|
|
expect(install.application).toMatchObject({
|
|
applicationKey: "paperclip.examples.safe-read-only-todo-kv",
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
});
|
|
expect(install.connection).toMatchObject({
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
config: expect.objectContaining({
|
|
templateId: "paperclip.synthetic-todo-kv",
|
|
}),
|
|
});
|
|
expect(install.profile).toMatchObject({
|
|
profileKey: "paperclip.examples.safe-read-only-todo-kv.profile",
|
|
defaultAction: "deny",
|
|
status: "active",
|
|
});
|
|
expect(install.profileBinding).toMatchObject({
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
});
|
|
expect(
|
|
install.profileEntries.map((entry) => entry.toolName).sort(),
|
|
).toEqual(["get_value", "list_items"]);
|
|
const installedCatalogByTool = new Map(
|
|
install.catalog.map((entry) => [entry.toolName, entry]),
|
|
);
|
|
expect(installedCatalogByTool.get("list_items")).toMatchObject({
|
|
status: "active",
|
|
riskLevel: "read",
|
|
});
|
|
expect(installedCatalogByTool.get("set_value")).toMatchObject({
|
|
status: "quarantined",
|
|
riskLevel: "write",
|
|
});
|
|
|
|
const smoke = await service.smokeExample(
|
|
company.id,
|
|
"safe-read-only-todo-kv",
|
|
{
|
|
actorType: "user",
|
|
actorId: "board",
|
|
},
|
|
);
|
|
|
|
expect(smoke.ok).toBe(true);
|
|
expect(smoke.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "allow_read_tool",
|
|
ok: true,
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
}),
|
|
expect.objectContaining({
|
|
name: "deny_write_tool",
|
|
ok: true,
|
|
decision: "deny",
|
|
reasonCode: "deny_default",
|
|
}),
|
|
expect.objectContaining({ name: "audit_written", ok: true }),
|
|
]),
|
|
);
|
|
const auditRows = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.companyId, company.id));
|
|
expect(
|
|
auditRows.some(
|
|
(row) =>
|
|
row.action === "tool_access.policy_decision" &&
|
|
row.reasonCode === "allow_profile",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
auditRows.some(
|
|
(row) =>
|
|
row.action === "tool_access.policy_decision" &&
|
|
row.reasonCode === "deny_default",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("evaluates enabled tool policies by priority with first-match wins", async () => {
|
|
const company = await createCompany(db);
|
|
const policyService = toolAccessPolicyService(db);
|
|
const [allowPolicy, blockPolicy] = await db
|
|
.insert(toolPolicies)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
name: `Allow first ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { toolName: "fixture:dangerous_action" },
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Block second ${randomUUID()}`,
|
|
policyType: "block",
|
|
priority: 200,
|
|
selectors: { toolName: "fixture:dangerous_action" },
|
|
},
|
|
])
|
|
.returning();
|
|
|
|
const allowDecision = await policyService.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "user", actorId: "board-user" },
|
|
request: { toolName: "fixture:dangerous_action", arguments: {} },
|
|
});
|
|
expect(allowDecision).toMatchObject({
|
|
decision: "allow",
|
|
reasonCode: "allow_policy",
|
|
matchedPolicyIds: [allowPolicy!.id],
|
|
});
|
|
|
|
await policyService.reorderPolicies(company.id, {
|
|
policyIds: [blockPolicy!.id, allowPolicy!.id],
|
|
});
|
|
const blockDecision = await policyService.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "user", actorId: "board-user" },
|
|
request: { toolName: "fixture:dangerous_action", arguments: {} },
|
|
});
|
|
expect(blockDecision).toMatchObject({
|
|
decision: "deny",
|
|
reasonCode: "deny_policy_block",
|
|
matchedPolicyIds: [blockPolicy!.id],
|
|
});
|
|
});
|
|
|
|
it("reorders and duplicates policies through board routes", async () => {
|
|
const company = await createCompany(db);
|
|
const [first, second] = await db
|
|
.insert(toolPolicies)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
name: `First policy ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { toolName: "read_notes" },
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Second policy ${randomUUID()}`,
|
|
policyType: "block",
|
|
priority: 200,
|
|
selectors: { toolName: "delete_notes" },
|
|
},
|
|
])
|
|
.returning();
|
|
const app = createRouteApp(db);
|
|
|
|
const reorder = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/policies/reorder`)
|
|
.send({ policyIds: [second!.id, first!.id] });
|
|
expect(reorder.status).toBe(200);
|
|
expect(
|
|
reorder.body.policies.map((policy: { id: string; priority: number }) => [
|
|
policy.id,
|
|
policy.priority,
|
|
]),
|
|
).toEqual([
|
|
[second!.id, 100],
|
|
[first!.id, 200],
|
|
]);
|
|
|
|
const duplicate = await request(app)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/policies/${first!.id}/duplicate`,
|
|
)
|
|
.send({});
|
|
expect(duplicate.status).toBe(201);
|
|
expect(duplicate.body).toMatchObject({
|
|
name: `${first!.name} copy`,
|
|
policyType: first!.policyType,
|
|
enabled: false,
|
|
selectors: first!.selectors,
|
|
});
|
|
|
|
const otherCompany = await createCompany(db);
|
|
const [foreignPolicy] = await db
|
|
.insert(toolPolicies)
|
|
.values({
|
|
companyId: otherCompany.id,
|
|
name: `Foreign policy ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: {},
|
|
})
|
|
.returning();
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/policies/reorder`)
|
|
.send({ policyIds: [second!.id, first!.id, foreignPolicy!.id] })
|
|
.expect(422);
|
|
|
|
const auditRows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.companyId, company.id));
|
|
expect(auditRows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ action: "tool_policy.reordered" }),
|
|
expect.objectContaining({ action: "tool_policy.duplicated" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("serves the app gallery manifest through the board route", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db, undefined, undefined, {
|
|
paperclipCloudConnector: null,
|
|
});
|
|
|
|
const res = await request(app).get(
|
|
`/api/companies/${company.id}/tools/gallery`,
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.capabilities).toEqual({
|
|
canCreateOrganizationGrant: true,
|
|
organizationGrantReason: null,
|
|
canSetCompanyInstall: true,
|
|
companyInstallReason: null,
|
|
});
|
|
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual(
|
|
expect.arrayContaining([
|
|
"browser-use-cloud",
|
|
"agentmail",
|
|
"imessage-photon",
|
|
"jira",
|
|
"airtable",
|
|
"asana",
|
|
"notion",
|
|
"posthog",
|
|
"sentry",
|
|
"zapier",
|
|
"linear",
|
|
"gmail",
|
|
"google-drive",
|
|
"google-docs",
|
|
"google-sheets",
|
|
"google-slides",
|
|
"google-calendar",
|
|
"google-chat",
|
|
"google-people",
|
|
"google-workspace-search",
|
|
"github",
|
|
"github-code-review-bot",
|
|
"youcom",
|
|
"enterpret",
|
|
"vercel",
|
|
"openrouter",
|
|
"bedrock",
|
|
"responses-api",
|
|
"messages-api",
|
|
"chat-completions-api",
|
|
"local",
|
|
"telem",
|
|
]),
|
|
);
|
|
expect(res.body.apps.length).toBeGreaterThanOrEqual(69);
|
|
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual(
|
|
expect.arrayContaining([
|
|
"calendly",
|
|
"exa",
|
|
"firecrawl",
|
|
"gsc-wizard",
|
|
"parallel-search",
|
|
"tavily",
|
|
"windsor-ai",
|
|
]),
|
|
);
|
|
for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
|
|
expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider");
|
|
}
|
|
expect(
|
|
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
|
|
.ownershipAvailability,
|
|
).toEqual({
|
|
platform_shared: false,
|
|
platform_provisioned: false,
|
|
customer: true,
|
|
dcr: true,
|
|
});
|
|
const gallerySlugs = new Set(
|
|
res.body.apps.map((app: { slug: string }) => app.slug),
|
|
);
|
|
expect(
|
|
[...APP_STORE_HIDDEN_SLUGS].filter((slug) => gallerySlugs.has(slug)),
|
|
).toEqual([]);
|
|
expect(
|
|
["g2", "zomato"].filter((slug) => gallerySlugs.has(slug)),
|
|
).toEqual([]);
|
|
expect(res.body.apps).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
slug: "posthog",
|
|
methods: expect.arrayContaining([
|
|
expect.objectContaining({ key: "mcp-oauth", auth: "oauth" }),
|
|
expect.objectContaining({ key: "mcp-api-key", auth: "api_key" }),
|
|
]),
|
|
}),
|
|
expect.objectContaining({
|
|
slug: "zapier",
|
|
methods: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
key: "generated-url",
|
|
auth: "none",
|
|
defaults: {},
|
|
}),
|
|
]),
|
|
}),
|
|
expect.objectContaining({
|
|
slug: "google-sheets",
|
|
methods: expect.arrayContaining([
|
|
expect.objectContaining({ key: "local", transport: "local_stdio" }),
|
|
]),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("quarantines newly discovered Enterpret tools on later refreshes", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "get_organization_details", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "enterpret",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "qa-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(result.connection.config).toMatchObject({
|
|
sourceTemplateKey: "enterpret",
|
|
quarantineNewEntries: true,
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain("qa-secret");
|
|
await service.finishGalleryAppConnection(company.id, result.connectionId, {
|
|
enabledCatalogEntryIds: result.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
});
|
|
fetchMock.mockResolvedValueOnce(
|
|
mcpHttpResponse(catalogResponse([
|
|
{ name: "get_organization_details", annotations: { readOnlyHint: true } },
|
|
{ name: "new_enterpret_tool", annotations: { readOnlyHint: true } },
|
|
])),
|
|
);
|
|
const refreshed = await service.refreshCatalog(result.connectionId, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
expect(refreshed.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "get_organization_details", status: "active" }),
|
|
expect.objectContaining({
|
|
toolName: "new_enterpret_tool",
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const reconnectFetchMock = mockToolsList([
|
|
{ name: "get_organization_details", annotations: { readOnlyHint: true } },
|
|
{ name: "new_enterpret_tool", annotations: { readOnlyHint: true } },
|
|
{ name: "reconnect_discovered_tool", annotations: { readOnlyHint: true } },
|
|
]);
|
|
const reconnected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "enterpret",
|
|
connectionMethodKey: "mcp-api-key",
|
|
reconnectConnectionId: result.connectionId,
|
|
credentialValues: { "credentials.authorization": "replacement-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(reconnected.connectionId).toBe(result.connectionId);
|
|
expect(reconnected.catalog).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "get_organization_details", status: "active" }),
|
|
expect.objectContaining({ toolName: "new_enterpret_tool", status: "quarantined" }),
|
|
expect.objectContaining({ toolName: "reconnect_discovered_tool", status: "quarantined" }),
|
|
]));
|
|
|
|
expect(reconnected.connection.config).toMatchObject({ quarantineNewEntries: true });
|
|
expect(JSON.stringify(reconnected.connection.config)).not.toContain("replacement-secret");
|
|
await service.finishGalleryAppConnection(company.id, reconnected.connectionId, {
|
|
enabledCatalogEntryIds: reconnected.catalog
|
|
.filter((entry) => entry.toolName === "get_organization_details")
|
|
.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
});
|
|
|
|
reconnectFetchMock.mockResolvedValueOnce(
|
|
mcpHttpResponse(catalogResponse([
|
|
{ name: "get_organization_details", annotations: { readOnlyHint: true } },
|
|
{ name: "new_enterpret_tool", annotations: { readOnlyHint: true } },
|
|
{ name: "another_enterpret_tool", annotations: { readOnlyHint: true } },
|
|
])),
|
|
);
|
|
const afterReplacement = await service.refreshCatalog(result.connectionId, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
expect(afterReplacement.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "another_enterpret_tool",
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("exposes managed Google methods only for profiles signed for this enrolled instance", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `gallery-pilot-${randomUUID()}`;
|
|
const pilotConnector = fakeGoogleWorkspaceConnector(
|
|
company.id,
|
|
userId,
|
|
"gmail.read",
|
|
);
|
|
const nonPilotConnector: PaperclipCloudConnector = {
|
|
...pilotConnector,
|
|
getCapabilities: vi.fn(async () => []),
|
|
};
|
|
|
|
const nonPilot = await request(
|
|
createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
undefined,
|
|
{ paperclipCloudConnector: nonPilotConnector },
|
|
),
|
|
).get(`/api/companies/${company.id}/tools/gallery`);
|
|
expect(nonPilot.status).toBe(200);
|
|
const nonPilotGmail = nonPilot.body.apps.find(
|
|
(app: { slug: string }) => app.slug === "gmail",
|
|
);
|
|
expect(nonPilotGmail.ownershipAvailability.platform_shared).toBe(false);
|
|
expect(
|
|
nonPilotGmail.methods.some(
|
|
(method: { oauthStrategy?: string }) =>
|
|
method.oauthStrategy === "paperclip_cloud_connector",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
nonPilotGmail.methods.map((method: { key: string }) => method.key),
|
|
).toEqual([
|
|
"customer-read-oauth",
|
|
"customer-draft-oauth",
|
|
"customer-archive-oauth",
|
|
]);
|
|
|
|
const pilot = await request(
|
|
createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
undefined,
|
|
{ paperclipCloudConnector: pilotConnector },
|
|
),
|
|
).get(`/api/companies/${company.id}/tools/gallery`);
|
|
expect(pilot.status).toBe(200);
|
|
const pilotGmail = pilot.body.apps.find(
|
|
(app: { slug: string }) => app.slug === "gmail",
|
|
);
|
|
expect(pilotGmail.ownershipAvailability.platform_shared).toBe(true);
|
|
expect(
|
|
pilotGmail.methods.map((method: { key: string }) => method.key),
|
|
).toEqual([
|
|
"paperclip-read",
|
|
"customer-read-oauth",
|
|
"customer-draft-oauth",
|
|
"customer-archive-oauth",
|
|
]);
|
|
|
|
const archivePilotConnector = fakeGoogleWorkspaceConnector(
|
|
company.id,
|
|
userId,
|
|
"gmail.archive",
|
|
);
|
|
const archivePilot = await request(
|
|
createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
undefined,
|
|
{ paperclipCloudConnector: archivePilotConnector },
|
|
),
|
|
).get(`/api/companies/${company.id}/tools/gallery`);
|
|
expect(archivePilot.status).toBe(200);
|
|
const archivePilotGmail = archivePilot.body.apps.find(
|
|
(app: { slug: string }) => app.slug === "gmail",
|
|
);
|
|
expect(archivePilotGmail.ownershipAvailability.platform_shared).toBe(true);
|
|
expect(
|
|
archivePilotGmail.methods.map((method: { key: string }) => method.key),
|
|
).toEqual([
|
|
"customer-read-oauth",
|
|
"customer-draft-oauth",
|
|
"paperclip-archive",
|
|
"customer-archive-oauth",
|
|
]);
|
|
});
|
|
|
|
it("preflights only public Jira metadata without credentials or OAuth registration", async () => {
|
|
const requests: Array<{
|
|
url: string;
|
|
method: string;
|
|
hasAuthorization: boolean;
|
|
}> = [];
|
|
const service = createTestToolAccessService(db, {
|
|
now: () => new Date("2026-08-26T12:00:00.000Z"),
|
|
remoteHttpRequest: async (url, init) => {
|
|
const method = (init.method ?? "GET").toUpperCase();
|
|
requests.push({
|
|
url,
|
|
method,
|
|
hasAuthorization: new Headers(init.headers).has("authorization"),
|
|
});
|
|
if (url === "https://mcp.atlassian.com/v1/mcp/authv2") {
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
if (url.includes("oauth-protected-resource")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
resource: "https://mcp.atlassian.com/v1/mcp/authv2",
|
|
authorization_servers: ["https://auth.atlassian.example"],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
if (url.startsWith("https://auth.atlassian.example/")) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
issuer: "https://auth.atlassian.example",
|
|
authorization_endpoint:
|
|
"https://auth.atlassian.example/authorize",
|
|
token_endpoint: "https://auth.atlassian.example/token",
|
|
registration_endpoint: "https://auth.atlassian.example/register",
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
return new Response(null, { status: 404 });
|
|
},
|
|
});
|
|
|
|
const result = await service.preflightGalleryAppMetadata(
|
|
"jira",
|
|
"mcp-oauth",
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
galleryKey: "jira",
|
|
methodKey: "mcp-oauth",
|
|
serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2",
|
|
endpointReachable: true,
|
|
oauth: {
|
|
metadataFound: true,
|
|
registrationAdvertised: true,
|
|
clientIdMetadataDocumentSupported: false,
|
|
},
|
|
checkedAt: "2026-08-26T12:00:00.000Z",
|
|
});
|
|
expect(requests.length).toBeGreaterThan(2);
|
|
expect(
|
|
requests.every(
|
|
(request) => request.method === "GET" && !request.hasAuthorization,
|
|
),
|
|
).toBe(true);
|
|
expect(requests.some((request) => request.url.endsWith("/register"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
|
|
|
|
it("retires saved Composio broker and child records without touching credentials until removal", async () => {
|
|
const company = await createCompany(db);
|
|
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
|
const remoteHttpRequest = vi.fn();
|
|
const service = createTestToolAccessService(db, { remoteHttpRequest });
|
|
for (const connection of [parent, child]) {
|
|
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
|
|
enabled: false, healthStatus: "error", healthMessage: expect.stringContaining("Add a new Composio MCP connection"),
|
|
});
|
|
await expect(service.checkHealth(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
|
|
await expect(service.refreshCatalog(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
|
|
await expect(service.reconnectGalleryApp(connection.id, company.id, { credentialValues: {} })).rejects.toMatchObject({ status: 422 });
|
|
await expect(service.connectGalleryApp(company.id, { galleryKey: "composio", connectionMethodKey: "mcp", reconnectConnectionId: connection.id })).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
|
|
}
|
|
expect(remoteHttpRequest).not.toHaveBeenCalled();
|
|
const [savedParent] = await db.select().from(toolConnections).where(eq(toolConnections.id, parent.id));
|
|
expect(savedParent.credentialSecretRefs).toEqual(parent.credentialSecretRefs);
|
|
// Each obsolete record can still be explicitly removed with ordinary cleanup.
|
|
for (const connection of [child, parent]) {
|
|
await expect(service.archiveConnection(connection.id, company.id)).resolves.toMatchObject({ connection: { status: "archived", enabled: false, credentialSecretRefs: [] } });
|
|
}
|
|
});
|
|
|
|
it("rejects old Composio API-key setup and removes toolkit-management routes", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
await expect(service.connectGalleryApp(company.id, {
|
|
galleryKey: "composio", connectionMethodKey: "api-key", credentialValues: { "credentials.apiKey": "obsolete" },
|
|
})).rejects.toMatchObject({ status: 422 });
|
|
const app = createRouteApp(db, boardSessionActor(company.id, "admin"));
|
|
const id = randomUUID();
|
|
await request(app).get(`/api/tool-connections/${id}/services`).expect(404);
|
|
await request(app).post(`/api/tool-connections/${id}/services/github/connect`).send({}).expect(404);
|
|
await request(app).get(`/api/tool-connections/${id}/services/github/status`).expect(404);
|
|
await request(app).delete(`/api/tool-connections/${id}/services/github`).expect(404);
|
|
});
|
|
|
|
it("returns server-derived create capabilities for a non-manager member", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db, boardSessionActor(company.id, "member"));
|
|
|
|
const res = await request(app).get(
|
|
`/api/companies/${company.id}/tools/gallery`,
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.capabilities).toEqual({
|
|
canCreateOrganizationGrant: false,
|
|
organizationGrantReason:
|
|
"Only a company owner, administrator, or connection manager can share this credential with the organization.",
|
|
canSetCompanyInstall: false,
|
|
companyInstallReason:
|
|
"Only someone who can configure this connection can choose this.",
|
|
});
|
|
});
|
|
|
|
it("requires connection-manager authority to create an organization credential", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db, boardSessionActor(company.id, "member"));
|
|
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "notion", grantKind: "organization" })
|
|
.expect(403);
|
|
// Omitted grantKind retains the legacy organization default, so it must
|
|
// pass through the same authorization check.
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "notion" })
|
|
.expect(403);
|
|
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("requires connection-manager authority to resume an organization credential", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const shared = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "notion",
|
|
grantKind: "organization",
|
|
},
|
|
{ actorType: "user", actorId: "member" },
|
|
);
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "member", "member"),
|
|
);
|
|
|
|
// The retained shared identity wins over a contradictory personal value.
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({
|
|
galleryKey: "notion",
|
|
resumeConnectionId: shared.connectionId,
|
|
grantKind: "user",
|
|
})
|
|
.expect(403);
|
|
|
|
// Omitting the explicit id must not bypass the same check through the
|
|
// service's retained draft lookup by application name and source.
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "notion", grantKind: "user" })
|
|
.expect(403);
|
|
});
|
|
|
|
it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/mcp/import-json`)
|
|
.send({
|
|
mcpJson: {
|
|
mcpServers: {
|
|
secure: {
|
|
url: "https://secure.example/mcp",
|
|
headers: {
|
|
Authorization: "Bearer raw-token",
|
|
"X-API-Key": "raw-key",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(JSON.stringify(res.body)).not.toContain("raw-token");
|
|
expect(JSON.stringify(res.body)).not.toContain("raw-key");
|
|
expect(res.body.drafts).toEqual([
|
|
expect.objectContaining({
|
|
name: "secure",
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
config: { url: "https://secure.example/mcp" },
|
|
credentialFields: [
|
|
expect.objectContaining({
|
|
configPath: "headers.Authorization",
|
|
key: "Authorization",
|
|
placement: "header",
|
|
}),
|
|
expect.objectContaining({
|
|
configPath: "headers.X-API-Key",
|
|
key: "X-API-Key",
|
|
placement: "header",
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("creates link-based MCP connections with imported header secrets before catalog review", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (_url, init) => {
|
|
const headers = init?.headers as Record<string, string>;
|
|
expect(headers.Authorization).toBe("Bearer imported-token");
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "kv_get",
|
|
description: "Read a value.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { key: { type: "string" } },
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]));
|
|
});
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://secure.example/mcp",
|
|
name: "Secure import",
|
|
credentialValues: { "headers.Authorization": "Bearer imported-token" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
expect(result.connection.status).toBe("draft");
|
|
expect(result.connection.credentialRefs).toEqual([
|
|
expect.objectContaining({
|
|
name: "headers.Authorization",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: null,
|
|
}),
|
|
]);
|
|
expect(result.connection.config).toMatchObject({
|
|
url: "https://secure.example/mcp",
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain(
|
|
"imported-token",
|
|
);
|
|
expect(result.actions.readOnly).toEqual([
|
|
expect.objectContaining({ toolName: "kv_get", riskLevel: "read" }),
|
|
]);
|
|
});
|
|
|
|
it("initializes stateful Streamable HTTP servers and remembers that future calls need a session", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const requests: Array<{ method: string; sessionId: string | null }> = [];
|
|
let sessionSequence = 0;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
|
|
const payload = JSON.parse(String(init?.body ?? "{}")) as {
|
|
method?: string;
|
|
id?: string;
|
|
};
|
|
const requestHeaders = new Headers(init?.headers);
|
|
requests.push({
|
|
method: payload.method ?? "",
|
|
sessionId: requestHeaders.get("mcp-session-id"),
|
|
});
|
|
if (payload.method === "initialize") {
|
|
sessionSequence += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: payload.id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "stateful", version: "1" },
|
|
},
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"mcp-session-id": `session-${sessionSequence}`,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
if (payload.method === "notifications/initialized") {
|
|
expect(requestHeaders.get("mcp-session-id")).toBeTruthy();
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
if (
|
|
payload.method === "tools/list" &&
|
|
!requestHeaders.get("mcp-session-id")
|
|
) {
|
|
return new Response(
|
|
JSON.stringify({ message: "Mcp-Session-Id header is required" }),
|
|
{
|
|
status: 400,
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
);
|
|
}
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: payload.id,
|
|
result: {
|
|
tools: [{ name: "list_state", annotations: { readOnlyHint: true } }],
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://stateful.example/mcp",
|
|
name: "Stateful MCP",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(result.connection.config).toMatchObject({
|
|
mcpSessionRequired: true,
|
|
});
|
|
expect(result.actions.readOnly).toEqual([
|
|
expect.objectContaining({ toolName: "list_state", riskLevel: "read" }),
|
|
]);
|
|
expect(requests[0]).toEqual({ method: "tools/list", sessionId: null });
|
|
expect(
|
|
requests.filter(({ method }) => method === "initialize").length,
|
|
).toBeGreaterThanOrEqual(1);
|
|
expect(
|
|
requests.filter(({ method }) => method === "notifications/initialized")
|
|
.length,
|
|
).toBe(requests.filter(({ method }) => method === "initialize").length);
|
|
});
|
|
|
|
it("serves persisted MCP actions until the cache expires and then refreshes them", async () => {
|
|
const company = await createCompany(db);
|
|
let currentTime = new Date("2026-08-20T12:00:00.000Z");
|
|
let tools = [
|
|
{
|
|
name: "cached_read",
|
|
description: "Read the cached value.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
];
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async () =>
|
|
mcpHttpResponse(catalogResponse(tools)),
|
|
);
|
|
const service = createTestToolAccessService(db, {
|
|
now: () => currentTime,
|
|
catalogCacheTtlMs: 60_000,
|
|
});
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://cache.example.test/mcp",
|
|
name: "Cached actions",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const discoveryCallsAfterConnect = fetchMock.mock.calls.length;
|
|
|
|
const cached = await service.listCatalog(connected.connectionId);
|
|
|
|
expect(cached.map((entry) => entry.toolName)).toContain("cached_read");
|
|
expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect);
|
|
|
|
tools = [
|
|
...tools,
|
|
{
|
|
name: "fresh_read",
|
|
description: "Read a newly discovered value.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
];
|
|
currentTime = new Date(currentTime.getTime() + 60_001);
|
|
|
|
const refreshed = await service.listCatalog(connected.connectionId);
|
|
|
|
expect(refreshed.map((entry) => entry.toolName)).toContain("fresh_read");
|
|
expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect + 1);
|
|
|
|
currentTime = new Date(currentTime.getTime() + 60_001);
|
|
fetchMock.mockRejectedValueOnce(new Error("temporary MCP outage"));
|
|
|
|
await expect(service.listCatalog(connected.connectionId)).resolves.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "fresh_read" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("commits a 'Just me' key to the caller's own grant and never to the connection or an organization grant", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: {
|
|
"credentials.authorization": "phx_personal-secret",
|
|
},
|
|
configValues: { projectId: "12345", mode: "tools" },
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: "carol" },
|
|
);
|
|
|
|
const { grants } = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const connection = await service.getConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
|
|
// The identity is the caller's, and it is the only grant: creating an
|
|
// organization grant first and "moving" the secret later is exactly what the
|
|
// design forbids, so there must be no organization grant at all.
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({
|
|
kind: "user",
|
|
subjectUserId: "carol",
|
|
status: "active",
|
|
});
|
|
expect(grants[0]!.credentialSecretRefs.length).toBeGreaterThan(0);
|
|
expect(grants.some((grant) => grant.kind === "organization")).toBe(false);
|
|
|
|
// The personal secret is not reachable as the connection's shared credential.
|
|
expect(connection.credentialSecretRefs).toEqual([]);
|
|
expect(connection.credentialPolicy).toBe("per_user");
|
|
|
|
// ...and the secret ids the personal grant holds appear nowhere on the row's
|
|
// shared secret-ref list, which is what an organization grant would copy.
|
|
const personalSecretIds = new Set(
|
|
grants[0]!.credentialSecretRefs.map((ref) => ref.secretId),
|
|
);
|
|
for (const ref of connection.credentialSecretRefs) {
|
|
expect(personalSecretIds.has(ref.secretId)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("keeps the shared-credential default when no grant kind is chosen", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "phx_shared-secret" },
|
|
configValues: { projectId: "12345", mode: "tools" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const { grants } = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const connection = await service.getConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({ kind: "organization", isDefault: true });
|
|
expect(grants[0]!.credentialSecretRefs.length).toBeGreaterThan(0);
|
|
expect(connection.credentialPolicy).toBe("shared");
|
|
});
|
|
|
|
it("validates a reviewed Vercel connector and stores no provider bearer or vault secret", async () => {
|
|
vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN",
|
|
"vercel-bootstrap-authority",
|
|
);
|
|
const company = await createCompany(db);
|
|
const getToken = vi.fn<VercelConnectClient["getToken"]>(async () => ({
|
|
token: "posthog-provider-bearer",
|
|
tokenId: "stk_posthog",
|
|
expiresAt: Date.now() + 60_000,
|
|
connector: {
|
|
id: "scl_posthog",
|
|
uid: "posthog-paperclip",
|
|
type: "api-key",
|
|
},
|
|
tenantId: "project-12345",
|
|
claims: { email: "must-not-persist@example.com" },
|
|
metadata: { providerSecret: "must-not-persist" },
|
|
}));
|
|
const revoke = vi.fn<VercelConnectClient["revoke"]>();
|
|
const vercelConnectClient: VercelConnectClient = {
|
|
getConnectorMetadata: vi.fn(async () => ({
|
|
id: "scl_posthog",
|
|
uid: "posthog-paperclip",
|
|
name: "Paperclip PostHog",
|
|
type: "api-key",
|
|
service: "mcp.posthog.com/mcp",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
vendor: { arbitrary: "public-but-not-persisted" },
|
|
})),
|
|
getToken,
|
|
startAuthorization: vi.fn(),
|
|
revoke,
|
|
evict: vi.fn(),
|
|
};
|
|
const observedAuthorization: string[] = [];
|
|
const service = createTestToolAccessService(db, {
|
|
vercelConnectClient,
|
|
remoteHttpRequest: async (_url, init) => {
|
|
observedAuthorization.push(
|
|
new Headers(init.headers).get("authorization") ?? "",
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
]));
|
|
},
|
|
});
|
|
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialSource: "vercel_connect",
|
|
vercelConnect: { connector: "posthog-paperclip" },
|
|
configValues: { projectId: "12345", mode: "tools" },
|
|
grantKind: "organization",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const [storedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
const [storedGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
const storedSecrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.companyId, company.id));
|
|
expect(connected.connection).toMatchObject({
|
|
credentialSource: "vercel_connect",
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
externalCredential: {
|
|
provider: "vercel_connect",
|
|
connectorId: "scl_posthog",
|
|
connectorUid: "posthog-paperclip",
|
|
service: "mcp.posthog.com/mcp",
|
|
principalMode: "app",
|
|
},
|
|
});
|
|
expect(observedAuthorization).toContain("Bearer posthog-provider-bearer");
|
|
expect(storedSecrets).toEqual([]);
|
|
expect(storedGrant?.externalCredential).toMatchObject({
|
|
tokenId: "stk_posthog",
|
|
subjectType: "app",
|
|
});
|
|
const durableAndApiState = JSON.stringify({
|
|
storedConnection,
|
|
storedGrant,
|
|
connected,
|
|
});
|
|
expect(durableAndApiState).not.toContain("posthog-provider-bearer");
|
|
expect(durableAndApiState).not.toContain("must-not-persist@example.com");
|
|
expect(durableAndApiState).not.toContain("vercel-bootstrap-authority");
|
|
|
|
const otherCompany = await createCompany(db);
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
otherCompany.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialSource: "vercel_connect",
|
|
vercelConnect: { connector: "posthog-paperclip" },
|
|
configValues: { projectId: "67890", mode: "tools" },
|
|
},
|
|
{ actorType: "user", actorId: "other-board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
details: { code: "vercel_connect_app_connector_in_use" },
|
|
});
|
|
|
|
const removed = await service.archiveConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(removed.connection).toMatchObject({
|
|
status: "archived",
|
|
enabled: false,
|
|
});
|
|
expect(removed.removal.externalCredentialCleanup).toMatchObject({
|
|
provider: "vercel_connect",
|
|
appSubjectCleanup: "manage_in_vercel",
|
|
userSubjectsAttempted: 0,
|
|
});
|
|
expect(revoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an attached Vercel connector for the wrong reviewed service", async () => {
|
|
vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN",
|
|
"vercel-bootstrap-authority",
|
|
);
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
vercelConnectClient: {
|
|
getConnectorMetadata: vi.fn(async () => ({
|
|
id: "scl_linear",
|
|
uid: "linear-paperclip",
|
|
name: "Linear",
|
|
type: "api-key",
|
|
service: "linear",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
vendor: {},
|
|
})),
|
|
getToken: vi.fn(),
|
|
startAuthorization: vi.fn(),
|
|
revoke: vi.fn(),
|
|
evict: vi.fn(),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialSource: "vercel_connect",
|
|
vercelConnect: { connector: "linear-paperclip" },
|
|
configValues: { projectId: "12345", mode: "tools" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
details: { code: "vercel_connect_service_mismatch" },
|
|
});
|
|
});
|
|
|
|
it("binds the Vercel OAuth callback to one company, actor, session, and one-time state", async () => {
|
|
vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN",
|
|
"vercel-bootstrap-authority",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board-user", [], "operator");
|
|
const getToken = vi.fn<VercelConnectClient["getToken"]>(async () => ({
|
|
token: "notion-provider-bearer",
|
|
tokenId: "stk_notion",
|
|
expiresAt: Date.now() + 60_000,
|
|
connector: { id: "scl_notion", uid: "notion-paperclip", type: "oauth" },
|
|
tenantId: "notion-workspace",
|
|
}));
|
|
const startAuthorization = vi.fn<VercelConnectClient["startAuthorization"]>(
|
|
async () => ({
|
|
request: "authorization-request",
|
|
verifier: "verifier-owned-by-vercel",
|
|
url: "https://vercel.com/connect/authorize/request-1",
|
|
expiresAt: Date.now() + 5 * 60_000,
|
|
}),
|
|
);
|
|
const revoke = vi.fn<VercelConnectClient["revoke"]>();
|
|
const service = createTestToolAccessService(db, {
|
|
vercelConnectClient: {
|
|
getConnectorMetadata: vi.fn(async () => ({
|
|
id: "scl_notion",
|
|
uid: "notion-paperclip",
|
|
name: "Paperclip Notion",
|
|
type: "oauth",
|
|
service: "notion",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
vendor: {},
|
|
})),
|
|
getToken,
|
|
startAuthorization,
|
|
revoke,
|
|
evict: vi.fn(),
|
|
},
|
|
remoteHttpRequest: async () =>
|
|
mcpHttpResponse(catalogResponse([
|
|
{ name: "search_pages", annotations: { readOnlyHint: true } },
|
|
])),
|
|
});
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "notion",
|
|
credentialSource: "vercel_connect",
|
|
vercelConnect: { connector: "notion-paperclip" },
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: "board-user", sessionId: "board-session" },
|
|
);
|
|
const actor = {
|
|
actorType: "user" as const,
|
|
actorId: "board-user",
|
|
sessionId: "board-session",
|
|
};
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "http://127.0.0.1:3100/api/tools/oauth/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const [stateRow] = await db
|
|
.select()
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.connectionId, connected.connectionId));
|
|
expect(started.authorizationUrl).toBe(
|
|
"https://vercel.com/connect/authorize/request-1",
|
|
);
|
|
expect(startAuthorization).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
connector: "notion-paperclip",
|
|
subject: expect.objectContaining({ type: "user" }),
|
|
resources: ["https://mcp.notion.com/mcp"],
|
|
}),
|
|
expect.stringMatching(
|
|
/^http:\/\/localhost:3100\/api\/tools\/vercel-connect\/callback\?state=/,
|
|
),
|
|
);
|
|
expect(stateRow).toMatchObject({
|
|
companyId: company.id,
|
|
createdByActorType: "user",
|
|
createdByActorId: "board-user",
|
|
createdBySessionId: "board-session",
|
|
codeVerifier: "vercel-connect",
|
|
});
|
|
|
|
await expect(
|
|
service.completeVercelConnectCallback({
|
|
state: stateRow!.state,
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "other-user",
|
|
sessionId: "other-session",
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "board-user"),
|
|
),
|
|
);
|
|
await expect(
|
|
service.completeVercelConnectCallback({ state: stateRow!.state, actor }),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
expect(getToken).not.toHaveBeenCalled();
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "operator" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "board-user"),
|
|
),
|
|
);
|
|
await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "http://127.0.0.1:3100/api/tools/oauth/callback",
|
|
actor,
|
|
});
|
|
const [restartedStateRow] = await db
|
|
.select()
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.connectionId, connected.connectionId));
|
|
expect(restartedStateRow?.state).toBeTruthy();
|
|
|
|
const completed = await service.completeVercelConnectCallback({
|
|
state: restartedStateRow!.state,
|
|
actor,
|
|
});
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
credentialSource: "vercel_connect",
|
|
});
|
|
expect(getToken).toHaveBeenCalledWith(expect.any(Object), {
|
|
forceRefresh: true,
|
|
});
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.state, stateRow!.state)),
|
|
).toEqual([]);
|
|
const grants = await service.listConnectionGrants(
|
|
completed.connectionId,
|
|
company.id,
|
|
);
|
|
expect(grants.grants[0]?.externalCredential).toMatchObject({
|
|
provider: "vercel_connect",
|
|
subjectType: "user",
|
|
tokenId: "stk_notion",
|
|
});
|
|
expect(JSON.stringify(grants)).not.toContain("subjectId");
|
|
expect(JSON.stringify({ completed, grants })).not.toContain(
|
|
"notion-provider-bearer",
|
|
);
|
|
const revoked = await service.revokeConnectionGrant(
|
|
completed.connectionId,
|
|
grants.grants[0]!.id,
|
|
actor,
|
|
);
|
|
expect(revoked.status).toBe("revoked");
|
|
expect(revoke).toHaveBeenCalledTimes(1);
|
|
await expect(
|
|
service.completeVercelConnectCallback({
|
|
state: restartedStateRow!.state,
|
|
actor,
|
|
}),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it("connects Ditto with a workspace API key sent with the token scheme", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "search_ditto_text" },
|
|
{ name: "translate_text_items" },
|
|
{ name: "delete_text_items" },
|
|
]);
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "ditto",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "ditto-test-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
// Ditto rejects the Bearer scheme for API keys; it expects `token <key>`.
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.dittowords.com/v2/mcp",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "token ditto-test-secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.connection).toMatchObject({
|
|
authKind: "api_key",
|
|
config: {
|
|
url: "https://api.dittowords.com/v2/mcp",
|
|
sourceTemplateKey: "ditto",
|
|
connectionMethodKey: "mcp-api-key",
|
|
},
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain("ditto-test-secret");
|
|
expect(result.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "search_ditto_text", riskLevel: "read", status: "active" }),
|
|
expect.objectContaining({ toolName: "translate_text_items", riskLevel: "write", status: "active" }),
|
|
expect.objectContaining({ toolName: "delete_text_items", riskLevel: "destructive", status: "active" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("connects Superagent with an organization API key sent as a bearer header", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "list_findings", annotations: { readOnlyHint: true } },
|
|
{ name: "create_repository_report" },
|
|
{ name: "delete_finding", annotations: { destructiveHint: true } },
|
|
]);
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "superagent",
|
|
credentialValues: { "credentials.authorization": "sk_live_test-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://www.superagent.sh/mcp",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer sk_live_test-secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.connection).toMatchObject({
|
|
authKind: "api_key",
|
|
config: {
|
|
url: "https://www.superagent.sh/mcp",
|
|
sourceTemplateKey: "superagent",
|
|
connectionMethodKey: "mcp-api-key",
|
|
},
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain(
|
|
"sk_live_test-secret",
|
|
);
|
|
expect(result.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "list_findings", riskLevel: "read", status: "active" }),
|
|
expect.objectContaining({ toolName: "create_repository_report", riskLevel: "write", status: "active" }),
|
|
expect.objectContaining({ toolName: "delete_finding", riskLevel: "destructive", status: "active" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("projects Neon's optional project pin and read-only mode into the hosted server URL", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
const pinned = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "neon",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Neon pinned",
|
|
configValues: { projectId: "shy-sun-12345678", readOnly: true },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(pinned.connection.config).toMatchObject({
|
|
url: "https://mcp.neon.tech/mcp?projectId=shy-sun-12345678&readonly=true",
|
|
sourceTemplateKey: "neon",
|
|
connectionMethodKey: "mcp-oauth",
|
|
methodConfig: { projectId: "shy-sun-12345678", readOnly: true },
|
|
});
|
|
|
|
// The default path sends Neon's own defaults: no pin, no readonly flag.
|
|
const unpinned = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "neon", connectionMethodKey: "mcp-oauth", name: "Neon unpinned" },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(unpinned.connection.config).toMatchObject({
|
|
url: "https://mcp.neon.tech/mcp",
|
|
methodConfig: { readOnly: false },
|
|
});
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "neon",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Neon invalid",
|
|
configValues: { projectId: "Shy Sun!" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it("resumes an interrupted configured OAuth draft instead of conflicting on its generated name", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const name = "Supabase for the company";
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name,
|
|
configValues: {
|
|
projectRef: "abcdefghijklmnopqrst",
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const resumed = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name,
|
|
configValues: {
|
|
projectRef: "tsrqponmlkjihgfedcba",
|
|
readOnly: true,
|
|
features: "database",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(resumed.connectionId).toBe(first.connectionId);
|
|
expect(resumed.application.id).toBe(first.application.id);
|
|
expect(resumed.connection).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
config: {
|
|
url: "https://mcp.supabase.com/mcp?project_ref=tsrqponmlkjihgfedcba&read_only=true&features=database",
|
|
sourceTemplateKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
methodConfig: {
|
|
projectRef: "tsrqponmlkjihgfedcba",
|
|
readOnly: true,
|
|
features: "database",
|
|
},
|
|
},
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, company.id)),
|
|
).resolves.toHaveLength(1);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, company.id)),
|
|
).resolves.toHaveLength(1);
|
|
});
|
|
|
|
it("resumes an explicitly selected draft even when its application is already active", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Supabase for the company",
|
|
configValues: {
|
|
projectRef: "abcdefghijklmnopqrst",
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "active" })
|
|
.where(eq(toolApplications.id, first.application.id));
|
|
|
|
const resumed = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
resumeConnectionId: first.connectionId,
|
|
name: "Supabase for the company",
|
|
configValues: {
|
|
projectRef: "tsrqponmlkjihgfedcba",
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(resumed.connectionId).toBe(first.connectionId);
|
|
expect(resumed.application.id).toBe(first.application.id);
|
|
expect(resumed.connection.config).toMatchObject({
|
|
sourceTemplateKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
methodConfig: {
|
|
projectRef: "tsrqponmlkjihgfedcba",
|
|
readOnly: true,
|
|
},
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, company.id)),
|
|
).resolves.toHaveLength(1);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, company.id)),
|
|
).resolves.toHaveLength(1);
|
|
});
|
|
|
|
it("reconnects an exact active custom MCP connection without duplicating its identity", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "archive_read", annotations: { readOnlyHint: true } },
|
|
]);
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://fixture.example/mcp",
|
|
authMode: "none",
|
|
name: "Archive",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "active", healthStatus: "error" })
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
mockToolsList([
|
|
{ name: "archive_read", annotations: { readOnlyHint: true } },
|
|
]);
|
|
const reconnected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://fixture.example/mcp",
|
|
authMode: "none",
|
|
reconnectConnectionId: first.connectionId,
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(reconnected.connectionId).toBe(first.connectionId);
|
|
const originalAgent = await createAgent(db, company.id);
|
|
const requester = await createAgent(db, company.id);
|
|
const ids = reconnected.actions.readOnly.map(
|
|
(action) => action.catalogEntryId,
|
|
);
|
|
await service.finishGalleryAppConnection(company.id, first.connectionId, {
|
|
enabledCatalogEntryIds: ids,
|
|
askFirstCatalogEntryIds: [],
|
|
access: { agentIds: [originalAgent.id] },
|
|
});
|
|
const additive = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
first.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: ids,
|
|
askFirstCatalogEntryIds: [],
|
|
access: { agentIds: [requester.id] },
|
|
preserveExistingAccess: true,
|
|
},
|
|
);
|
|
expect(additive.profileBindings.map((binding) => binding.targetId)).toEqual(
|
|
expect.arrayContaining([originalAgent.id, requester.id]),
|
|
);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, company.id)),
|
|
).toHaveLength(1);
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "notion", reconnectConnectionId: first.connectionId },
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toThrow("preserve the configured provider");
|
|
const other = await createCompany(db);
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
other.id,
|
|
{
|
|
link: "https://fixture.example/mcp",
|
|
reconnectConnectionId: first.connectionId,
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toThrow("not found");
|
|
});
|
|
|
|
it("refuses a personal identity when no named user is making the request", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "phx_agent-secret" },
|
|
configValues: { projectId: "12345", mode: "tools" },
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "agent", actorId: "agent-1" },
|
|
),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it("keeps a saved Telem.AI header policy when the connection is reconnected", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
mockToolsList([{ name: "telem_search" }]);
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "telem",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "tlm_first-key" },
|
|
},
|
|
actor,
|
|
);
|
|
await service.updateConnection(first.connectionId, {
|
|
status: "active",
|
|
config: { ...first.connection.config, headerPolicy: { metadata: { forward: [] } } },
|
|
});
|
|
|
|
mockToolsList([{ name: "telem_search" }]);
|
|
const reconnected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "telem",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "tlm_second-key" },
|
|
reconnectConnectionId: first.connectionId,
|
|
},
|
|
actor,
|
|
);
|
|
|
|
expect(reconnected.connectionId).toBe(first.connectionId);
|
|
expect(reconnected.connection.config.headerPolicy).toEqual({ metadata: { forward: [] } });
|
|
});
|
|
|
|
it("forwards Paperclip context headers by default for a Telem.AI connection", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([{ name: "telem_search" }]);
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "telem",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "tlm_test-secret" },
|
|
configValues: { tier: "extended" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(result.connection.config).toMatchObject({
|
|
sourceTemplateKey: "telem",
|
|
methodConfig: { tier: "extended" },
|
|
headerPolicy: {
|
|
metadata: {
|
|
forward: ["company_id", "issue_id", "agent_id", "run_id", "project_id", "correlation_id"],
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain("tlm_test-secret");
|
|
});
|
|
|
|
it("requires an explicit PostHog method and projects optional validated project filters", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
configValues: { projectId: "12345", features: "insights" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
|
|
const fetchMock = mockToolsList([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
{ name: "delete_feature_flag" },
|
|
{ name: "brand_new_tool" },
|
|
]);
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "phx_test-secret" },
|
|
configValues: {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights, error_tracking\ninsights",
|
|
tools: "query_insight",
|
|
mode: "tools",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer phx_test-secret",
|
|
"x-posthog-project-id": "12345",
|
|
}),
|
|
}),
|
|
);
|
|
expect(result.connection).toMatchObject({
|
|
authKind: "api_key",
|
|
config: {
|
|
sourceTemplateKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
methodConfig: {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights,error_tracking",
|
|
tools: "query_insight",
|
|
mode: "tools",
|
|
},
|
|
safeDefault: true,
|
|
},
|
|
});
|
|
expect(JSON.stringify(result.connection.config)).not.toContain(
|
|
"phx_test-secret",
|
|
);
|
|
expect(result.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "query_insight",
|
|
riskLevel: "read",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_feature_flag",
|
|
riskLevel: "destructive",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "brand_new_tool",
|
|
riskLevel: "write",
|
|
status: "active",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("connects PostHog with provider defaults and no project pin", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const result = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "posthog",
|
|
connectionMethodKey: "mcp-api-key",
|
|
credentialValues: { "credentials.authorization": "phx_test-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://mcp.posthog.com/mcp?mode=tools",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer phx_test-secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty(
|
|
"x-posthog-project-id",
|
|
);
|
|
expect(result.connection.config).toMatchObject({
|
|
methodConfig: { readOnly: false, mode: "tools" },
|
|
});
|
|
});
|
|
|
|
it("stores approved class-3 credential refs on thin tool connections", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [secret] = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId: company.id,
|
|
key: `discord.bot_token.${randomUUID()}`,
|
|
name: `Discord bot token ${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
})
|
|
.returning();
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
applicationName: "Discord",
|
|
name: "Discord bot token",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://discord.example.test/mcp" },
|
|
enabled: false,
|
|
status: "draft",
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: secret!.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.bot_token",
|
|
label: "Discord bot token",
|
|
projectionClass: "class_3_static_lease",
|
|
projectionAllowlistKey: "discord.bot_token",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(connection.credentialSecretRefs).toEqual([
|
|
expect.objectContaining({
|
|
secretId: secret!.id,
|
|
configPath: "credentials.bot_token",
|
|
projectionClass: "class_3_static_lease",
|
|
projectionAllowlistKey: "discord.bot_token",
|
|
}),
|
|
]);
|
|
const bindings = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, company.id),
|
|
eq(companySecretBindings.targetId, connection.id),
|
|
),
|
|
);
|
|
expect(bindings).toEqual([
|
|
expect.objectContaining({
|
|
secretId: secret!.id,
|
|
targetType: "tool_connection",
|
|
configPath: "credentials.bot_token",
|
|
projectionClass: "class_3_static_lease",
|
|
projectionAllowlistKey: "discord.bot_token",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("rejects class-3 tool connection refs outside the enumerated allowlist", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `blocked-${randomUUID()}`,
|
|
name: `Blocked App ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [secret] = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId: company.id,
|
|
key: `github.token.${randomUUID()}`,
|
|
name: `GitHub token ${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
})
|
|
.returning();
|
|
|
|
await expect(
|
|
service.createConnection(company.id, {
|
|
applicationId: application!.id,
|
|
name: "Blocked class-3 token",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://blocked.example.test/mcp" },
|
|
enabled: false,
|
|
status: "draft",
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: secret!.id,
|
|
versionSelector: "latest",
|
|
configPath: "credentials.bot_token",
|
|
label: "GitHub token",
|
|
projectionClass: "class_3_static_lease",
|
|
projectionAllowlistKey: "github.token",
|
|
},
|
|
],
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: { code: "class_3_static_lease_not_allowed" },
|
|
});
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("rejects Google Sheets gallery connects that claim a spreadsheet bound to another company", async () => {
|
|
const companyA = await createCompany(db);
|
|
const companyB = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.stubEnv(
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
JSON.stringify({
|
|
client_email: "robot@example.iam.gserviceaccount.com",
|
|
}),
|
|
);
|
|
|
|
await service.connectGalleryApp(
|
|
companyB.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Company B sheets",
|
|
configValues: { allowedSpreadsheetIds: ["shared-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board-b" },
|
|
);
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
companyA.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Company A sheets",
|
|
configValues: { allowedSpreadsheetIds: ["shared-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board-a" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "google_sheets_spreadsheet_already_bound",
|
|
spreadsheetIds: ["shared-sheet"],
|
|
},
|
|
});
|
|
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
|
|
});
|
|
|
|
it("stores Google Sheets catalog input schemas from the approved stdio template", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.stubEnv(
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
JSON.stringify({
|
|
client_email: "robot@example.iam.gserviceaccount.com",
|
|
}),
|
|
);
|
|
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Company sheets",
|
|
configValues: { allowedSpreadsheetIds: ["sheet-with-inputs"] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const descriptions = Object.fromEntries(
|
|
connect.catalog.map((entry) => [entry.toolName, entry.description]),
|
|
);
|
|
expect(descriptions).toMatchObject({
|
|
list_spreadsheets:
|
|
"List the Google Sheets spreadsheets configured in this connection allowlist.",
|
|
get_spreadsheet_info:
|
|
"Get spreadsheet metadata and sheet tab information for an allowlisted spreadsheet.",
|
|
read_values: "Read cell values from an allowlisted spreadsheet range.",
|
|
search_rows: "Search rows in an allowlisted spreadsheet range.",
|
|
append_rows: "Append rows to an allowlisted spreadsheet range.",
|
|
update_values: "Update values in an allowlisted spreadsheet range.",
|
|
add_sheet_tab: "Add a sheet tab to an allowlisted spreadsheet.",
|
|
clear_values: "Clear values in an allowlisted spreadsheet range.",
|
|
delete_rows: "Delete rows from an allowlisted spreadsheet tab.",
|
|
});
|
|
expect(
|
|
connect.catalog.find((entry) => entry.toolName === "read_values")
|
|
?.inputSchema,
|
|
).toMatchObject({
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: expect.objectContaining({ type: "string" }),
|
|
range: expect.objectContaining({ type: "string" }),
|
|
},
|
|
required: ["spreadsheetId", "range"],
|
|
});
|
|
expect(
|
|
connect.catalog.find((entry) => entry.toolName === "append_rows")
|
|
?.inputSchema,
|
|
).toMatchObject({
|
|
properties: {
|
|
spreadsheetId: expect.objectContaining({ type: "string" }),
|
|
range: expect.objectContaining({ type: "string" }),
|
|
values: expect.objectContaining({ type: "array" }),
|
|
valueInputOption: expect.objectContaining({
|
|
enum: ["RAW", "USER_ENTERED"],
|
|
}),
|
|
},
|
|
required: ["spreadsheetId", "range", "values"],
|
|
});
|
|
expect(
|
|
connect.catalog.find((entry) => entry.toolName === "delete_rows")
|
|
?.inputSchema,
|
|
).toMatchObject({
|
|
properties: {
|
|
spreadsheetId: expect.objectContaining({ type: "string" }),
|
|
sheetId: expect.objectContaining({ type: "integer" }),
|
|
startIndex: expect.objectContaining({ type: "integer" }),
|
|
endIndex: expect.objectContaining({ type: "integer" }),
|
|
},
|
|
required: ["spreadsheetId", "sheetId", "startIndex", "endIndex"],
|
|
});
|
|
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({ inputSchema: { type: "object", properties: {} } })
|
|
.where(
|
|
eq(
|
|
toolCatalogEntries.id,
|
|
connect.catalog.find((entry) => entry.toolName === "read_values")!.id,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
(await service.listCatalog(connect.connectionId)).find(
|
|
(entry) => entry.toolName === "read_values",
|
|
)?.inputSchema,
|
|
).toMatchObject({
|
|
properties: {
|
|
spreadsheetId: expect.objectContaining({ type: "string" }),
|
|
range: expect.objectContaining({ type: "string" }),
|
|
},
|
|
required: ["spreadsheetId", "range"],
|
|
});
|
|
});
|
|
|
|
it("rejects raw Google Sheets connection patches that claim another company's spreadsheet", async () => {
|
|
const companyA = await createCompany(db);
|
|
const companyB = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
vi.stubEnv(
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
JSON.stringify({
|
|
client_email: "robot@example.iam.gserviceaccount.com",
|
|
}),
|
|
);
|
|
|
|
await service.connectGalleryApp(
|
|
companyB.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Company B sheets",
|
|
configValues: { allowedSpreadsheetIds: ["company-b-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board-b" },
|
|
);
|
|
const companyAConnection = await service.connectGalleryApp(
|
|
companyA.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Company A sheets",
|
|
configValues: { allowedSpreadsheetIds: ["company-a-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board-a" },
|
|
);
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/tool-connections/${companyAConnection.connectionId}`)
|
|
.send({
|
|
config: {
|
|
templateId: "paperclip.google-sheets",
|
|
sourceTemplateKey: "google-sheets",
|
|
allowedSpreadsheetIds: ["company-b-sheet"],
|
|
env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-b-sheet" },
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({
|
|
error:
|
|
"Google Sheets spreadsheet is already connected to another company.",
|
|
details: {
|
|
code: "google_sheets_spreadsheet_already_bound",
|
|
spreadsheetIds: ["company-b-sheet"],
|
|
},
|
|
});
|
|
const [stillCompanyA] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, companyAConnection.connectionId));
|
|
expect(stillCompanyA.config.allowedSpreadsheetIds).toEqual([
|
|
"company-a-sheet",
|
|
]);
|
|
expect(stillCompanyA.config.env).toMatchObject({
|
|
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-a-sheet",
|
|
});
|
|
});
|
|
|
|
it("tags a pause PATCH with a lifecycle activity row the Activity tab can surface", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Google Sheets"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Sheets",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://sheets.example/mcp" },
|
|
transportConfig: { url: "https://sheets.example/mcp" },
|
|
})
|
|
.returning();
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ enabled: false });
|
|
expect(res.status).toBe(200);
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, company.id),
|
|
eq(activityLog.entityId, connection.id),
|
|
),
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.details).toMatchObject({
|
|
lifecycle: "paused",
|
|
enabled: false,
|
|
});
|
|
|
|
const activity = await service.listConnectionActivity(
|
|
connection.id,
|
|
company.id,
|
|
20,
|
|
);
|
|
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual([
|
|
"app_paused",
|
|
]);
|
|
});
|
|
|
|
it("preserves all active personal OAuth declarations through pause, resume, and metadata edits", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({ companyId: company.id, name: "GitHub", type: "mcp_http" })
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "GitHub",
|
|
uid: randomUUID(),
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: "per_user",
|
|
config: {
|
|
url: "https://api.githubcopilot.com/mcp/",
|
|
sourceTemplateKey: "github",
|
|
},
|
|
})
|
|
.returning();
|
|
const [sharedDefinition] = await db
|
|
.insert(userSecretDefinitions)
|
|
.values({
|
|
companyId: company.id,
|
|
key: randomUUID(),
|
|
name: "OAuth access token",
|
|
})
|
|
.returning();
|
|
const definitionIds = [sharedDefinition.id];
|
|
for (const user of ["A", "B", "revoked"]) {
|
|
const definition =
|
|
user === "revoked"
|
|
? (
|
|
await db
|
|
.insert(userSecretDefinitions)
|
|
.values({
|
|
companyId: company.id,
|
|
key: randomUUID(),
|
|
name: "Revoked identity",
|
|
})
|
|
.returning()
|
|
)[0]
|
|
: sharedDefinition;
|
|
const [secret] = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId: company.id,
|
|
key: randomUUID(),
|
|
name: user,
|
|
scope: "user",
|
|
ownerUserId: user,
|
|
userSecretDefinitionId: definition.id,
|
|
})
|
|
.returning();
|
|
await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: user,
|
|
status: user === "revoked" ? "revoked" : "active",
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId: secret.id,
|
|
configPath: "oauth.access_token",
|
|
versionSelector: "latest",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
for (const edit of [
|
|
{ enabled: false },
|
|
{ enabled: true },
|
|
{ name: "Renamed GitHub" },
|
|
]) {
|
|
await service.updateConnection(connection.id, edit);
|
|
const declarations = await db
|
|
.select()
|
|
.from(userSecretDeclarations)
|
|
.where(eq(userSecretDeclarations.targetId, connection.id));
|
|
expect(
|
|
declarations.map((row) => row.userSecretDefinitionId).sort(),
|
|
).toEqual([...definitionIds].sort());
|
|
expect(
|
|
declarations.every((row) => row.configPath === "oauth.access_token"),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.stubEnv(
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
JSON.stringify({
|
|
client_email: "robot@example.iam.gserviceaccount.com",
|
|
}),
|
|
);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "First sheets",
|
|
configValues: { allowedSpreadsheetIds: ["same-company-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const second = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-sheets",
|
|
connectionMethodKey: "local",
|
|
name: "Second sheets",
|
|
configValues: { allowedSpreadsheetIds: ["same-company-sheet"] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const updated = await service.updateConnection(second.connectionId, {
|
|
config: {
|
|
templateId: "paperclip.google-sheets",
|
|
sourceTemplateKey: "google-sheets",
|
|
allowedSpreadsheetIds: [
|
|
"same-company-sheet",
|
|
"new-company-sheet",
|
|
"same-company-sheet",
|
|
],
|
|
env: {
|
|
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "attacker-controlled-sheet",
|
|
EXTRA_ENV: "preserved",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(first.connection.config.allowedSpreadsheetIds).toEqual([
|
|
"same-company-sheet",
|
|
]);
|
|
expect(updated.config.allowedSpreadsheetIds).toEqual([
|
|
"same-company-sheet",
|
|
"new-company-sheet",
|
|
]);
|
|
expect(updated.config.env).toEqual({
|
|
EXTRA_ENV: "preserved",
|
|
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS:
|
|
"same-company-sheet,new-company-sheet",
|
|
});
|
|
expect(updated.transportConfig).toEqual(updated.config);
|
|
});
|
|
|
|
it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) => [
|
|
["local_trusted", "private", "http://127.0.0.1:3102"] as const,
|
|
["authenticated", "public", "https://tenant.paperclip.app"] as const,
|
|
].map(([deploymentMode, deploymentExposure, origin]) => ({ profile, deploymentMode, deploymentExposure, origin }))))(
|
|
"connects advertised Workspace $profile without mutating definitions in $deploymentMode",
|
|
async ({ profile, deploymentMode, deploymentExposure, origin }) => {
|
|
const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug;
|
|
const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key;
|
|
const company = await createCompany(db);
|
|
const userId = "board-user";
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
|
|
const definitionBefore = JSON.stringify(getConnectableAppDefinition(slug));
|
|
const app = createRouteApp(db,
|
|
deploymentMode === "authenticated" ? boardSessionActor(company.id, "owner", userId) : undefined,
|
|
undefined, { deploymentMode, deploymentExposure, paperclipCloudConnector: connector });
|
|
const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`);
|
|
const workspaceApp = gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug);
|
|
expect(workspaceApp.methods.map((method: { key: string }) => method.key)).toContain(methodKey);
|
|
const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({
|
|
galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Personal ${slug}`,
|
|
...(profile === "workspace.all" ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
|
});
|
|
expect(connected.status).toBe(201);
|
|
expect(connected.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" });
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const started = await service.startOAuth(company.id, connected.body.connectionId, {
|
|
redirectUri: `${origin}/api/tools/oauth/cloud-connector/callback`, actor,
|
|
});
|
|
expect(connector.startAuthorization).toHaveBeenCalledWith(expect.objectContaining({
|
|
profile, companyId: company.id, subject: userId,
|
|
returnUri: `${origin}/api/tools/oauth/cloud-connector/callback`,
|
|
}));
|
|
mockToolsList([]);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: `${profile}-claim`, actor,
|
|
});
|
|
expect(completed.connection).toMatchObject({ status: "active", credentialPolicy: "per_user" });
|
|
expect(JSON.stringify(getConnectableAppDefinition(slug))).toBe(definitionBefore);
|
|
});
|
|
|
|
it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS)("connects advertised Workspace %s with a Cloud-delivered environment identity", async (profile) => {
|
|
const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug;
|
|
const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key;
|
|
const company = await createCompany(db);
|
|
const userId = `cloud-workspace-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const signing = generateKeyPairSync("ed25519");
|
|
const sealing = generateKeyPairSync("x25519");
|
|
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://tenant.paperclip.app");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", "https://my.paperclip.app");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT", "production");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID", "inst-cloud-workspace-regression");
|
|
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", signing.privateKey.export({ type: "pkcs8", format: "pem" }).toString());
|
|
vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY", sealing.privateKey.export({ type: "pkcs8", format: "pem" }).toString());
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
const cloudRequest = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const signed = JSON.parse(String(init?.body)).request as string;
|
|
const claims = JSON.parse(Buffer.from(signed.split(".")[1]!, "base64url").toString());
|
|
expect(claims).toMatchObject({ iss: "inst-cloud-workspace-regression", env: "production" });
|
|
if (String(url) === "https://my.paperclip.app/v1/connector/instance-status") {
|
|
expect(claims.op).toBe("status");
|
|
return Response.json({ active: true, status: "active", profiles: [profile] });
|
|
}
|
|
expect(String(url)).toBe("https://my.paperclip.app/v1/connector/sessions");
|
|
expect(claims).toMatchObject({
|
|
op: "session", prf: profile, cid: company.id, sub: userId,
|
|
ruri: "https://tenant.paperclip.app/api/tools/oauth/cloud-connector/callback",
|
|
});
|
|
return Response.json({
|
|
confirmationUrl: "https://my.paperclip.app/connections/confirm?id=test-workspace-session",
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
});
|
|
});
|
|
try {
|
|
const app = createRouteApp(db, boardSessionActor(company.id, "owner", userId), undefined, {
|
|
deploymentMode: "authenticated", deploymentExposure: "public",
|
|
});
|
|
const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`);
|
|
expect(gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug).methods
|
|
.map((method: { key: string }) => method.key)).toContain(methodKey);
|
|
const result = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({
|
|
galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Cloud ${slug}`,
|
|
...(profile === "workspace.all" ? { workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION } : {}),
|
|
});
|
|
expect(result.status, JSON.stringify(result.body)).toBe(201);
|
|
expect(result.body.auth.startUrl).toBe("https://my.paperclip.app/connections/confirm?id=test-workspace-session");
|
|
expect(result.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" });
|
|
expect(cloudRequest).toHaveBeenCalled();
|
|
} finally {
|
|
invalidatePaperclipCloudConnectorCapabilities();
|
|
}
|
|
});
|
|
|
|
it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) =>
|
|
[false, true].map((advertiseOther) => ({ profile, advertiseOther })),
|
|
))("rejects unavailable Workspace $profile (other profile advertised: $advertiseOther)", async ({ profile, advertiseOther }) => {
|
|
const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug;
|
|
const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key;
|
|
const company = await createCompany(db);
|
|
const connector = fakeGmailConnector(company.id, "board-user");
|
|
connector.getCapabilities = vi.fn(async (): Promise<GoogleWorkspaceConnectorProfileId[]> =>
|
|
advertiseOther ? [profile === "gmail.read" ? "drive.read" : "gmail.read"] : [],
|
|
);
|
|
const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: connector });
|
|
const response = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({
|
|
galleryKey: slug, connectionMethodKey: methodKey, name: `Unavailable ${slug}`,
|
|
});
|
|
expect(response.status).toBe(422);
|
|
expect(connector.startAuthorization).not.toHaveBeenCalled();
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toEqual([]);
|
|
});
|
|
|
|
it("completes brokered Gmail OAuth with a single database connection", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `gmail-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, []);
|
|
const callbackDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const connector = fakeGmailConnector(company.id, userId);
|
|
const service = createTestToolAccessService(callbackDb, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
let deadline: ReturnType<typeof setTimeout> | null = null;
|
|
mockToolsList([]);
|
|
|
|
try {
|
|
await callbackDb.execute(sql`select pg_backend_pid()`);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "gmail",
|
|
connectionMethodKey: "paperclip-draft",
|
|
grantKind: "user",
|
|
name: "Gmail single-pool callback",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
|
|
const completed = await Promise.race([
|
|
service.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "gmail-claim",
|
|
actor,
|
|
}),
|
|
new Promise<never>((_resolve, reject) => {
|
|
deadline = setTimeout(() => {
|
|
void callbackDb.$client
|
|
.end({ timeout: 0 })
|
|
.finally(() =>
|
|
reject(
|
|
new Error(
|
|
"Gmail OAuth callback self-deadlocked with maxConnections=1",
|
|
),
|
|
),
|
|
);
|
|
}, 5_000);
|
|
}),
|
|
]);
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
const [grant] = await callbackDb
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
expect(grant).toMatchObject({ status: "active" });
|
|
expect(
|
|
grant!.credentialSecretRefs.map((ref) => ref.configPath).sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
await expect(
|
|
service.revokeConnectionGrant(connected.connectionId, grant!.id, actor),
|
|
).resolves.toMatchObject({ status: "revoked" });
|
|
expect(connector.revoke).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (deadline) clearTimeout(deadline);
|
|
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
}
|
|
}, 15_000);
|
|
|
|
it("reports GitHub reauthorization for the viewer without borrowing another user's grant", async () => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "GitHub authorization fixture"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "GitHub authorization fixture",
|
|
uid: randomUUID(),
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: "per_user",
|
|
createdByUserId: "A",
|
|
config: { sourceTemplateKey: "github" },
|
|
})
|
|
.returning();
|
|
await db
|
|
.insert(connectionGrants)
|
|
.values(
|
|
["A", "B"].map((user) => ({
|
|
companyId: company.id,
|
|
connectionId: connection!.id,
|
|
kind: "user" as const,
|
|
subjectUserId: user,
|
|
status: user === "A" ? ("revoked" as const) : ("active" as const),
|
|
credentialSecretRefs: [],
|
|
})),
|
|
);
|
|
const service = createTestToolAccessService(db);
|
|
expect(
|
|
(await service.getConnection(connection!.id, company.id, "A"))
|
|
.requiresReauthorization,
|
|
).toBe(true);
|
|
expect(
|
|
(await service.getConnection(connection!.id, company.id, "B"))
|
|
.requiresReauthorization,
|
|
).toBe(false);
|
|
expect(
|
|
(await service.listConnections(company.id, "A"))[0]
|
|
?.requiresReauthorization,
|
|
).toBe(true);
|
|
expect(
|
|
(await service.listConnections(company.id, "B"))[0]
|
|
?.requiresReauthorization,
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each(["none", "event", "same-time-refresh", "one-conflict"])(
|
|
"binds a managed GitHub identity and protects refresh from concurrent access changes (%s)",
|
|
async (concurrentChange) => {
|
|
const company = await createCompany(db);
|
|
const userId = `github-manager-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const agent = await createAgent(db, company.id);
|
|
const connector = fakeGitHubConnector(company.id, `agent:${agent.id}`);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const githubDefinition = getConnectableAppDefinition("github")!;
|
|
const previousOwnershipAvailability =
|
|
githubDefinition.ownershipAvailability;
|
|
githubDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
let beforeRepositoryResponse = async () => {};
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://api.github.com/user") {
|
|
return mcpHttpResponse({
|
|
id: 42,
|
|
login: "octocat",
|
|
avatar_url: "https://avatars.example/octocat",
|
|
});
|
|
}
|
|
if (href.includes("https://api.github.com/user/installations?")) {
|
|
return mcpHttpResponse({
|
|
installations: [
|
|
{
|
|
id: 101,
|
|
repository_selection: "selected",
|
|
html_url: "https://github.com/settings/installations/101",
|
|
account: { login: "paperclipai" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (
|
|
href.includes(
|
|
"https://api.github.com/user/installations/101/repositories?",
|
|
)
|
|
) {
|
|
await beforeRepositoryResponse();
|
|
return mcpHttpResponse({
|
|
total_count: 3,
|
|
repositories: [1, 2, 3].map((id) => ({
|
|
id,
|
|
full_name: `paperclipai/repo-${id}`,
|
|
description: "do-not-store",
|
|
})),
|
|
});
|
|
}
|
|
if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "get_pull_request",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "managed",
|
|
grantKind: "agent",
|
|
subjectAgentId: agent.id,
|
|
name: "Agent GitHub",
|
|
},
|
|
actor,
|
|
);
|
|
expect(connected.connection.credentialPolicy).toBe("per_agent");
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
subjectAgentId: agent.id,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "operator" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, userId),
|
|
),
|
|
);
|
|
await expect(
|
|
service.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "github-agent-claim",
|
|
actor,
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:manage_connections",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
});
|
|
const completed = await service.completePaperclipCloudConnectorCallback(
|
|
{
|
|
state,
|
|
claimId: "github-agent-claim",
|
|
actor,
|
|
},
|
|
);
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
credentialPolicy: "per_agent",
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.kind, "agent"),
|
|
eq(connectionGrants.subjectAgentId, agent.id),
|
|
),
|
|
);
|
|
expect(grant).toMatchObject({
|
|
status: "active",
|
|
subjectUserId: null,
|
|
isDefault: false,
|
|
providerTenant: {
|
|
name: "octocat",
|
|
oauth: {
|
|
strategy: "paperclip_cloud_connector",
|
|
accessTokenExpiresAt: null,
|
|
},
|
|
github: {
|
|
userId: "42",
|
|
login: "octocat",
|
|
installationCount: 1,
|
|
repositoryCount: 3,
|
|
repositorySelection: "selected",
|
|
installationIds: ["101"],
|
|
installationUrl:
|
|
"https://github.com/apps/paperclip-development/installations/new",
|
|
managementUrl: "https://github.com/settings/installations/101",
|
|
appSlug: "paperclip-development",
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
grant!.credentialSecretRefs.map((ref) => ref.configPath),
|
|
).toEqual(["oauth.access_token"]);
|
|
expect(JSON.stringify(grant)).not.toContain("do-not-store");
|
|
expect(connector.setWebhookBinding).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
subject: `agent:${agent.id}`,
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
grantId: grant!.id,
|
|
installationId: "101",
|
|
active: true,
|
|
}),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "agent"),
|
|
eq(toolConnectionInstalls.targetId, agent.id),
|
|
),
|
|
),
|
|
).resolves.toHaveLength(1);
|
|
vi.mocked(connector.setWebhookBinding).mockClear();
|
|
if (concurrentChange !== "none") {
|
|
beforeRepositoryResponse = async () => {
|
|
if (concurrentChange === "one-conflict")
|
|
beforeRepositoryResponse = async () => {};
|
|
const [latest] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: {
|
|
...latest!.providerTenant,
|
|
github: {
|
|
...latest!.providerTenant!.github!,
|
|
accessRevision: randomUUID(),
|
|
// Simulate a refresh with identical timestamps, so only the unique
|
|
// access revision can distinguish its newer access snapshot.
|
|
...(concurrentChange === "event"
|
|
? { lastWebhookAt: new Date().toISOString() }
|
|
: {}),
|
|
installationIds: [],
|
|
installationCount: 0,
|
|
repositoryCount: 0,
|
|
repositorySelection: "none",
|
|
repositories: undefined,
|
|
webhookHealth: "unhealthy",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
};
|
|
if (concurrentChange === "one-conflict") {
|
|
await expect(
|
|
service.checkHealth(connected.connectionId, actor),
|
|
).resolves.toMatchObject({ connection: { healthStatus: "ok" } });
|
|
expect(connector.setWebhookBinding).toHaveBeenCalled();
|
|
const [latest] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
expect(latest?.status).toBe("active");
|
|
expect(latest?.providerTenant?.github?.repositoryCount).toBe(3);
|
|
return;
|
|
}
|
|
await expect(
|
|
service.checkHealth(connected.connectionId, actor),
|
|
).rejects.toThrow("GitHub access changed during refresh. Try again.");
|
|
const [latest] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant!.id));
|
|
expect(latest?.providerTenant?.github).toMatchObject({
|
|
installationIds: [],
|
|
repositoryCount: 0,
|
|
webhookHealth: "unhealthy",
|
|
});
|
|
expect(latest?.providerTenant?.github?.repositories).toBeUndefined();
|
|
expect(connector.setWebhookBinding).not.toHaveBeenCalled();
|
|
} else {
|
|
await expect(
|
|
service.checkHealth(connected.connectionId, actor),
|
|
).resolves.toMatchObject({ connection: { healthStatus: "ok" } });
|
|
expect(connector.setWebhookBinding).toHaveBeenCalled();
|
|
}
|
|
} finally {
|
|
githubDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
},
|
|
);
|
|
|
|
it("replaces an archived dedicated GitHub identity with an explicitly selected personal identity", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `github-personal-revival-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const agent = await createAgent(db, company.id);
|
|
const connector = fakeGitHubConnector(company.id, `agent:${agent.id}`);
|
|
const originalClaim = connector.claim;
|
|
connector.claim = vi.fn(async (input) => ({
|
|
...(await originalClaim(input)),
|
|
subject: input.subject,
|
|
}));
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const githubDefinition = getConnectableAppDefinition("github")!;
|
|
const previousOwnershipAvailability =
|
|
githubDefinition.ownershipAvailability;
|
|
githubDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://api.github.com/user") {
|
|
return mcpHttpResponse({ id: 42, login: "octocat" });
|
|
}
|
|
if (href.includes("https://api.github.com/user/installations?")) {
|
|
return mcpHttpResponse({
|
|
installations: [
|
|
{
|
|
id: 101,
|
|
repository_selection: "selected",
|
|
html_url: "https://github.com/settings/installations/101",
|
|
account: { login: "paperclipai" },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (
|
|
href.includes(
|
|
"https://api.github.com/user/installations/101/repositories?",
|
|
)
|
|
) {
|
|
return mcpHttpResponse({
|
|
total_count: 1,
|
|
repositories: [{ id: 1, full_name: "paperclipai/repo-1" }],
|
|
});
|
|
}
|
|
if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "get_pull_request", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
try {
|
|
const dedicated = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "managed",
|
|
grantKind: "agent",
|
|
subjectAgentId: agent.id,
|
|
name: "GitHub",
|
|
},
|
|
actor,
|
|
);
|
|
const dedicatedStart = await service.startOAuth(
|
|
company.id,
|
|
dedicated.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
subjectAgentId: agent.id,
|
|
},
|
|
);
|
|
await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(dedicatedStart.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!,
|
|
claimId: "github-dedicated-before-removal",
|
|
actor,
|
|
});
|
|
await service.archiveConnection(
|
|
dedicated.connectionId,
|
|
company.id,
|
|
actor,
|
|
);
|
|
|
|
const personal = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "managed",
|
|
grantKind: "user",
|
|
name: "GitHub",
|
|
},
|
|
actor,
|
|
);
|
|
expect(personal.connectionId).toBe(dedicated.connectionId);
|
|
expect(personal.connection).toMatchObject({
|
|
status: "draft",
|
|
credentialPolicy: "per_user",
|
|
});
|
|
|
|
const personalStart = await service.startOAuth(
|
|
company.id,
|
|
personal.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(personalStart.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!,
|
|
claimId: "github-personal-after-removal",
|
|
actor,
|
|
});
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
credentialPolicy: "per_user",
|
|
});
|
|
|
|
const grants = await service.listConnectionGrants(
|
|
personal.connectionId,
|
|
company.id,
|
|
);
|
|
expect(grants.grants).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "agent",
|
|
subjectAgentId: agent.id,
|
|
status: "revoked",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
status: "active",
|
|
}),
|
|
]),
|
|
);
|
|
expect(grants.grants.some((grant) => grant.kind === "organization")).toBe(
|
|
false,
|
|
);
|
|
} finally {
|
|
githubDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it.each(["user", "organization"] as const)("builds one Workspace catalog from actual partial consent for a %s grant", async (grantKind) => {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
|
const fullClaim = connector.claim;
|
|
connector.claim = vi.fn(async (input) => ({ ...await fullClaim(input), scopes: ["https://www.googleapis.com/auth/documents"] }));
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const provider = mockToolsList([
|
|
{ name: "read_doc", annotations: { readOnlyHint: true } },
|
|
{ name: "update_doc", annotations: { readOnlyHint: true } },
|
|
{ name: "delete_doc" },
|
|
]);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind,
|
|
}, actor);
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor,
|
|
workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION,
|
|
});
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "workspace-claim", actor,
|
|
});
|
|
expect(completed.connection.status).toBe("active");
|
|
expect(completed.catalog.map((tool) => tool.toolName).sort()).toEqual(["docs__read_doc", "docs__update_doc"]);
|
|
expect(completed.catalog.find((tool) => tool.toolName === "docs__update_doc")?.riskLevel).toBe("write");
|
|
expect(provider.mock.calls.length).toBeGreaterThan(0);
|
|
expect(new Set(provider.mock.calls.map(([url]) => String(url)))).toEqual(new Set(["https://docsmcp.googleapis.com/mcp/v1"]));
|
|
const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id));
|
|
expect(saved).toHaveLength(1);
|
|
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
expect(grants.find((grant) => grant.status === "active")?.providerTenant?.oauth?.scopes).toEqual(["https://www.googleapis.com/auth/documents"]);
|
|
const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
expect(profiles).toHaveLength(1);
|
|
expect(JSON.stringify(completed)).not.toContain("workspace-access-token");
|
|
});
|
|
|
|
describe("combined Workspace service isolation and refresh consent", () => {
|
|
async function connectWorkspace(mode: "managed" | "customer", permissions = ["documents.readonly", "gmail.readonly"]) {
|
|
const company = await createCompany(db);
|
|
const userId = `workspace-refresh-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const initialScopes = permissions.map((scope) => `https://www.googleapis.com/auth/${scope}`);
|
|
let authorizationScopes = initialScopes;
|
|
let refreshScopes: string[] | undefined = initialScopes;
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
|
const originalClaim = connector.claim;
|
|
connector.claim = vi.fn(async (input) => ({ ...await originalClaim(input), scopes: initialScopes,
|
|
accessTokenExpiresAt: new Date(Date.now() + 86_400_000).toISOString() }));
|
|
connector.refresh = vi.fn(async () => ({ ...await connector.claim({ subject: userId, companyId: company.id,
|
|
profile: "workspace.all", claimId: "fixture", redemptionId: "fixture" }), scopes: refreshScopes ?? [] }));
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const calls: string[] = [];
|
|
const unavailable = new Set<string>();
|
|
const omitted = new Set<string>();
|
|
let unauthorizedNextCall = false;
|
|
const tokenRefreshes: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://oauth2.googleapis.com/token") {
|
|
const refreshing = new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token";
|
|
if (refreshing) tokenRefreshes.push(href);
|
|
return Response.json({ access_token: "workspace-refresh-access", refresh_token: "workspace-refresh-token",
|
|
token_type: "Bearer", expires_in: 86_400, scope: (refreshing ? refreshScopes : authorizationScopes)?.join(" ") });
|
|
}
|
|
const host = new URL(href).hostname;
|
|
if (unavailable.has(host)) return new Response("unavailable", { status: 503 });
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "tools/call") {
|
|
calls.push(body.params.name);
|
|
if (unauthorizedNextCall) { unauthorizedNextCall = false; return new Response("expired", { status: 401 }); }
|
|
return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "ok" }] } });
|
|
}
|
|
const names = host === "docsmcp.googleapis.com" ? ["read_doc", "update_doc"] : ["get_message"];
|
|
return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: omitted.has(host) ? [] : names.map((name) => ({
|
|
name, annotations: { readOnlyHint: name !== "update_doc" },
|
|
inputSchema: { type: "object", properties: {} },
|
|
})) } });
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "google-workspace", grantKind: "organization",
|
|
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
|
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-client-secret" } } : {}),
|
|
}, actor);
|
|
const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback";
|
|
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, workspaceServices: GOOGLE_WORKSPACE_DEFAULT_SELECTION });
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
|
const completed = mode === "managed"
|
|
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "fixture", actor })
|
|
: await service.completeOAuthCallback({ state, code: "fixture", redirectUri, actor });
|
|
const agent = await createAgent(db, company.id);
|
|
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
|
enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
|
}, actor);
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
|
invocationSource: "on_demand", status: "running", contextSnapshot: {} }).returning();
|
|
const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret",
|
|
oauthGrantRefresher: service.refreshOAuthGrantCredentials });
|
|
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
|
return { company, actor, agent, service, connected, gateway, session, calls, unavailable, omitted, connector, tokenRefreshes,
|
|
setAuthorizationScopes: (scopes: string[]) => { authorizationScopes = scopes; },
|
|
expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } };
|
|
}
|
|
|
|
it("preserves organization actions when a personal OAuth callback grants fewer permissions", async () => {
|
|
const fixture = await connectWorkspace("customer", ["documents", "gmail.readonly"]);
|
|
// This supported policy selects personal consent when present and the
|
|
// organization identity for callers without a personal grant.
|
|
await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" })
|
|
.where(eq(toolConnections.id, fixture.connected.connectionId));
|
|
const userId = `workspace-personal-${randomUUID()}`;
|
|
await grantBoardUser(db, fixture.company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
fixture.setAuthorizationScopes(["https://www.googleapis.com/auth/documents.readonly"]);
|
|
const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, {
|
|
redirectUri, actor, subjectUserId: userId,
|
|
});
|
|
await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "personal-docs-only", redirectUri, actor });
|
|
const [gmailEntry] = await db.select().from(toolCatalogEntries).where(and(
|
|
eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"),
|
|
));
|
|
expect(gmailEntry.status).toBe("active");
|
|
const [docsWrite] = await db.select().from(toolCatalogEntries).where(and(
|
|
eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "docs__update_doc"),
|
|
));
|
|
// Docs was probed, but its write action was filtered by personal consent,
|
|
// not removed by Google. The organization still retains that action.
|
|
expect(docsWrite.status).toBe("active");
|
|
const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!;
|
|
expect(gmail).toBeTruthy();
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
|
await expect(fixture.gateway.executeTestCall({ companyId: fixture.company.id, connectionId: fixture.connected.connectionId,
|
|
agentId: fixture.agent.id, userId, toolName: "gmail__get_message", parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } });
|
|
expect(fixture.calls).toEqual(["get_message"]);
|
|
// Once the other authority is revoked, a personal refresh can remove the
|
|
// unsupported shared entry; revoked grants never preserve capabilities.
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(and(
|
|
eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.kind, "organization"),
|
|
));
|
|
await fixture.service.refreshCatalog(fixture.connected.connectionId, actor);
|
|
const [removed] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, gmailEntry.id));
|
|
expect(removed.status).toBe("disabled");
|
|
});
|
|
|
|
it.each(["outage", "removed"] as const)("does not preserve a probed Gmail service's missing tools across grants (%s)", async (failure) => {
|
|
const fixture = await connectWorkspace("customer");
|
|
const userId = `workspace-both-${randomUUID()}`;
|
|
await grantBoardUser(db, fixture.company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { redirectUri, actor, subjectUserId: userId });
|
|
await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-both", redirectUri, actor });
|
|
(failure === "outage" ? fixture.unavailable : fixture.omitted).add("gmailmcp.googleapis.com");
|
|
const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, actor);
|
|
expect(refreshed.connection.healthStatus).toBe("ok");
|
|
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
|
expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false);
|
|
const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
|
expect(doc).toBeTruthy();
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
|
expect(fixture.calls).toEqual(["read_doc"]);
|
|
const [gmail] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message")));
|
|
expect(gmail.status).toBe("disabled");
|
|
});
|
|
|
|
it("keeps healthy service tools visible during a partial outage and restores recovered services", async () => {
|
|
const fixture = await connectWorkspace("managed");
|
|
fixture.unavailable.add("gmailmcp.googleapis.com");
|
|
const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
|
expect(refreshed.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
|
expect(refreshed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
|
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
|
const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
|
expect(doc).toBeTruthy();
|
|
expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false);
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
|
const health = await fixture.service.checkHealth(fixture.connected.connectionId, fixture.actor);
|
|
expect(health.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
|
const events = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, fixture.connected.connectionId));
|
|
expect(events).toContainEqual(expect.objectContaining({ reasonCode: "google_workspace_service_unavailable", details: { service: "gmail" } }));
|
|
fixture.unavailable.clear();
|
|
const recovered = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
|
expect(recovered.catalog.map((entry) => entry.toolName).sort()).toEqual(["docs__read_doc", "gmail__get_message"]);
|
|
expect(recovered.connection.healthMessage).not.toContain("unavailable");
|
|
fixture.unavailable.add("gmailmcp.googleapis.com");
|
|
fixture.unavailable.add("docsmcp.googleapis.com");
|
|
await expect(fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor)).rejects.toMatchObject({ details: { code: "google_workspace_services_unavailable" } });
|
|
});
|
|
|
|
it("does not let a cached Gmail call failure hide healthy Docs actions", async () => {
|
|
const fixture = await connectWorkspace("customer");
|
|
const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!;
|
|
fixture.unavailable.add("gmailmcp.googleapis.com");
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" });
|
|
expect((await fixture.service.getConnection(fixture.connected.connectionId)).healthStatus).toBe("ok");
|
|
const doc = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
|
expect(doc).toBeTruthy();
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
|
expect(fixture.calls).toEqual(["read_doc"]);
|
|
});
|
|
|
|
it.each(["managed", "customer"] as const)("blocks explicit retry and updates listing after %s refresh narrows consent", async (mode) => {
|
|
const fixture = await connectWorkspace(mode);
|
|
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
|
expect(tool).toBeTruthy();
|
|
const scopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
|
fixture.expireNextCall(scopes);
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_refreshed_retry_required" });
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" });
|
|
expect(fixture.calls).toEqual(["read_doc"]); // No second tools/call after the 401 refresh removed Docs permission.
|
|
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.status, "active")));
|
|
expect(grant.providerTenant?.oauth?.scopes).toEqual(scopes);
|
|
if (mode === "managed") expect(fixture.connector.refresh).toHaveBeenCalled();
|
|
else expect(fixture.tokenRefreshes).toHaveLength(1);
|
|
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
|
expect(tools.some((item) => item.upstreamToolName === "docs__read_doc")).toBe(false);
|
|
const gmail = tools.find((item) => item.upstreamToolName === "gmail__get_message")!;
|
|
expect(gmail).toBeTruthy();
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
|
});
|
|
|
|
it.each((["managed", "customer"] as const).flatMap((mode) => [undefined, [], ["https://www.googleapis.com/auth/gmail.send"]].map((scopes) => ({ mode, scopes }))))(
|
|
"fails closed on invalid refreshed permissions ($mode, $scopes)", async ({ mode, scopes }) => {
|
|
const fixture = await connectWorkspace(mode);
|
|
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
|
fixture.expireNextCall(scopes);
|
|
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_reauthorization_required" });
|
|
expect(fixture.calls).toEqual(["read_doc"]);
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, fixture.connected.connectionId));
|
|
expect(grant.status).toBe("needs_reauthorization");
|
|
expect((await fixture.gateway.listToolsForSession(fixture.session.token)).some((item) => item.connectionId === fixture.connected.connectionId)).toBe(false);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `drive-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const profile = "drive.read" as const;
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
|
|
connector.startAuthorization = vi.fn(async ({ returnState }) => ({
|
|
authorizationUrl: `https://my.example.test/connections/confirm?session=legacy&state=${encodeURIComponent(returnState)}`,
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
handoff: {
|
|
kind: "paperclip_cloud" as const,
|
|
session: "cloud_session_abcdefghijklmnop",
|
|
},
|
|
}));
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
|
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
|
driveDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([
|
|
{ name: "search_files", annotations: { readOnlyHint: true } },
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-read",
|
|
grantKind: "user",
|
|
name: "Drive managed read",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
expect(started.handoff).toEqual({
|
|
kind: "paperclip_cloud",
|
|
session: "cloud_session_abcdefghijklmnop",
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
undefined,
|
|
{ paperclipCloudConnector: connector },
|
|
);
|
|
|
|
const callback = await request(app)
|
|
.get("/api/tools/oauth/cloud-connector/callback")
|
|
.query({ state, claim_id: "drive-claim" })
|
|
.set("accept", "application/json");
|
|
|
|
expect(callback.status).toBe(200);
|
|
expect(connector.startAuthorization).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
subject: userId,
|
|
profile,
|
|
}),
|
|
);
|
|
expect(connector.claim).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
subject: userId,
|
|
profile,
|
|
claimId: "drive-claim",
|
|
redemptionId: state,
|
|
}),
|
|
);
|
|
expect(callback.body.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "ok",
|
|
config: {
|
|
sourceTemplateKey: "google-drive",
|
|
quarantineNewEntries: true,
|
|
oauth: {
|
|
strategy: "paperclip_cloud_connector",
|
|
provider: "google-drive",
|
|
connectorProfile: profile,
|
|
resource: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].serverUrl,
|
|
scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes],
|
|
},
|
|
},
|
|
});
|
|
expect(callback.body.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "active",
|
|
riskLevel: "read",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "disabled",
|
|
riskLevel: "write",
|
|
}),
|
|
]),
|
|
);
|
|
const [profileRow] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
const profileEntries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profileRow!.id));
|
|
expect(profileEntries).toHaveLength(1);
|
|
expect(profileEntries[0]).toMatchObject({
|
|
catalogEntryId: callback.body.catalog.find(
|
|
(entry: { toolName: string }) => entry.toolName === "search_files",
|
|
).id,
|
|
effect: "include",
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "company"),
|
|
),
|
|
),
|
|
).resolves.toHaveLength(1);
|
|
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
expect(grant).toMatchObject({
|
|
status: "active",
|
|
providerTenant: {
|
|
name: "Google Drive",
|
|
oauth: {
|
|
strategy: "paperclip_cloud_connector",
|
|
scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes],
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
grant!.credentialSecretRefs.map((ref) => ref.configPath).sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
const secrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
inArray(
|
|
companySecrets.id,
|
|
grant!.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
);
|
|
expect(secrets).toHaveLength(2);
|
|
expect(secrets).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: userId,
|
|
provider: "local_encrypted",
|
|
}),
|
|
]),
|
|
);
|
|
const versions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
inArray(
|
|
companySecretVersions.secretId,
|
|
grant!.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
);
|
|
expect(versions).toHaveLength(2);
|
|
expect(JSON.stringify(versions)).not.toContain("drive-access-token");
|
|
expect(JSON.stringify(versions)).not.toContain("drive-refresh-token");
|
|
|
|
const [activity] = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(
|
|
and(
|
|
eq(activityLog.entityId, connected.connectionId),
|
|
eq(activityLog.action, "tool_app.oauth_connected"),
|
|
),
|
|
);
|
|
expect(activity?.details).toMatchObject({
|
|
applicationId: callback.body.application.id,
|
|
catalogEntryCount: 2,
|
|
provider: "google-drive",
|
|
profile,
|
|
});
|
|
expect(JSON.stringify(activity?.details)).not.toContain(userId);
|
|
expect(JSON.stringify(activity?.details)).not.toContain("token");
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("routes a managed Drive callback into the shared organization vault when selected", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `shared-drive-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const profile = "drive.read" as const;
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
|
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
|
driveDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([
|
|
{ name: "search_files", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-read",
|
|
grantKind: "organization",
|
|
name: "Shared Drive managed read",
|
|
},
|
|
actor,
|
|
);
|
|
expect(connected.connection).toMatchObject({
|
|
credentialPolicy: "shared",
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
claimId: "shared-drive-claim",
|
|
actor,
|
|
});
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: "shared",
|
|
config: {
|
|
oauth: {
|
|
strategy: "paperclip_cloud_connector",
|
|
connectorSubjectUserId: userId,
|
|
},
|
|
},
|
|
});
|
|
expect(completed.connection.credentialRefs).toEqual([
|
|
expect.objectContaining({
|
|
name: "oauth.access_token",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}),
|
|
]);
|
|
expect(
|
|
completed.connection.credentialSecretRefs
|
|
.map((ref) => ref.configPath)
|
|
.sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
|
|
const grants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({
|
|
kind: "organization",
|
|
subjectUserId: null,
|
|
isDefault: true,
|
|
status: "active",
|
|
providerTenant: {
|
|
externalId: userId,
|
|
oauth: { strategy: "paperclip_cloud_connector" },
|
|
},
|
|
});
|
|
expect(
|
|
grants[0]!.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
).toEqual(
|
|
completed.connection.credentialSecretRefs
|
|
.map((ref) => ref.secretId)
|
|
.sort(),
|
|
);
|
|
|
|
const secrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
inArray(
|
|
companySecrets.id,
|
|
completed.connection.credentialSecretRefs.map(
|
|
(ref) => ref.secretId,
|
|
),
|
|
),
|
|
);
|
|
expect(secrets).toHaveLength(2);
|
|
expect(
|
|
secrets.every(
|
|
(secret) => secret.scope === "company" && secret.ownerUserId === null,
|
|
),
|
|
).toBe(true);
|
|
const bindings = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, connected.connectionId),
|
|
),
|
|
);
|
|
expect(bindings.map((binding) => binding.configPath).sort()).toEqual([
|
|
"oauth.access_token",
|
|
"oauth.refresh_token",
|
|
]);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("activates allowed Drive write actions without approval defaults after a managed callback", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `drive-write-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const profile = "drive.write" as const;
|
|
const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
|
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
|
driveDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([
|
|
{ name: "search_files", annotations: { readOnlyHint: true } },
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
{ name: "delete_file", annotations: { destructiveHint: true } },
|
|
]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-write",
|
|
grantKind: "user",
|
|
name: "Drive managed write",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
undefined,
|
|
{ paperclipCloudConnector: connector },
|
|
);
|
|
|
|
const callback = await request(app)
|
|
.get("/api/tools/oauth/cloud-connector/callback")
|
|
.query({ state, claim_id: "drive-write-claim" })
|
|
.set("accept", "application/json");
|
|
|
|
expect(callback.status).toBe(200);
|
|
expect(callback.body.connection.config.quarantineNewEntries).toBe(true);
|
|
expect(callback.body.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "active",
|
|
riskLevel: "read",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "active",
|
|
riskLevel: "write",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
riskLevel: "destructive",
|
|
}),
|
|
]),
|
|
);
|
|
const [profileRow] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
const profileEntries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profileRow!.id));
|
|
expect(
|
|
profileEntries.map((entry) => entry.catalogEntryId).sort(),
|
|
).toEqual(
|
|
callback.body.catalog
|
|
.filter((entry: { status: string }) => entry.status === "active")
|
|
.map((entry: { id: string }) => entry.id)
|
|
.sort(),
|
|
);
|
|
const searchEntry = callback.body.catalog.find(
|
|
(entry: { toolName: string }) => entry.toolName === "search_files",
|
|
);
|
|
const createEntry = callback.body.catalog.find(
|
|
(entry: { toolName: string }) => entry.toolName === "create_file",
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "company"),
|
|
),
|
|
),
|
|
).resolves.toHaveLength(1);
|
|
|
|
const agent = await createAgent(db, company.id);
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.profileId, profileRow!.id),
|
|
eq(toolProfileEntries.catalogEntryId, searchEntry.id),
|
|
),
|
|
);
|
|
await db
|
|
.delete(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, profileRow!.id));
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: company.id,
|
|
profileId: profileRow!.id,
|
|
targetType: "agent",
|
|
targetId: agent.id,
|
|
});
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ status: "archived" })
|
|
.where(eq(toolProfiles.id, profileRow!.id));
|
|
|
|
mockToolsList([
|
|
{
|
|
name: "search_files",
|
|
description: "Search files with a changed contract.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
{ name: "copy_file", annotations: { readOnlyHint: false } },
|
|
{ name: "delete_file", annotations: { destructiveHint: true } },
|
|
]);
|
|
const reconnect = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const reconnectState = new URL(
|
|
reconnect.authorizationUrl,
|
|
).searchParams.get("state")!;
|
|
const reconnected = await request(app)
|
|
.get("/api/tools/oauth/cloud-connector/callback")
|
|
.query({
|
|
state: reconnectState,
|
|
claim_id: "drive-write-reconnect-claim",
|
|
})
|
|
.set("accept", "application/json");
|
|
|
|
expect(reconnected.status).toBe(200);
|
|
expect(reconnected.body.connection.config.quarantineNewEntries).toBe(
|
|
true,
|
|
);
|
|
expect(reconnected.body.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "copy_file",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
}),
|
|
]),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, profileRow!.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "archived" })]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profileRow!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
catalogEntryId: createEntry.id,
|
|
effect: "include",
|
|
}),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, profileRow!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(eq(toolPolicies.companyId, company.id)),
|
|
).resolves.toEqual([]);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("keeps a managed draft retryable when default finalization fails", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `drive-finalize-failure-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const connector = fakeGoogleWorkspaceConnector(
|
|
company.id,
|
|
userId,
|
|
"drive.write",
|
|
);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
|
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
|
driveDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([
|
|
{ name: "search_files", annotations: { readOnlyHint: true } },
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
{ name: "delete_file", annotations: { destructiveHint: true } },
|
|
]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-write",
|
|
grantKind: "user",
|
|
name: "Drive managed finalize failure",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
const originalTransaction = db.transaction.bind(db);
|
|
const transactionSpy = vi
|
|
.spyOn(db, "transaction")
|
|
.mockImplementation((async (operation, config) => originalTransaction(async tx => {
|
|
const insert = tx.insert.bind(tx);
|
|
const insertSpy = vi.spyOn(tx, "insert").mockImplementation(((table: Parameters<typeof tx.insert>[0]) => {
|
|
if (table === toolProfiles) throw new Error("recommended defaults failed");
|
|
return insert(table);
|
|
}) as typeof tx.insert);
|
|
try { return await operation(tx); }
|
|
finally { insertSpy.mockRestore(); }
|
|
}, config)) as typeof db.transaction);
|
|
|
|
await expect(
|
|
service.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "drive-finalize-failure-claim",
|
|
actor,
|
|
}),
|
|
).rejects.toThrow("recommended defaults failed");
|
|
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connected.connectionId)),
|
|
).resolves.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
}),
|
|
]),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)),
|
|
).resolves.toHaveLength(0);
|
|
await expect(
|
|
service.getConnection(connected.connectionId),
|
|
).resolves.toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
config: { quarantineNewEntries: true },
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connected.application.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "draft" })]);
|
|
|
|
transactionSpy.mockRestore();
|
|
const retry = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(retry.authorizationUrl).searchParams.get("state")!,
|
|
claimId: "drive-finalize-retry-claim",
|
|
actor,
|
|
});
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
config: { quarantineNewEntries: true },
|
|
});
|
|
expect(completed.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
}),
|
|
]),
|
|
);
|
|
const [profileRow] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
const activeEntryIds = completed.catalog
|
|
.filter((entry) => entry.status === "active")
|
|
.map((entry) => entry.id)
|
|
.sort();
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profileRow!.id))
|
|
.then((rows) => rows.map((entry) => entry.catalogEntryId).sort()),
|
|
).resolves.toEqual(activeEntryIds);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, profileRow!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
}),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "company"),
|
|
),
|
|
),
|
|
).resolves.toHaveLength(1);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connected.application.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "active" })]);
|
|
|
|
mockToolsList([
|
|
{
|
|
name: "search_files",
|
|
description: "Changed after retry.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
{ name: "copy_file", annotations: { readOnlyHint: false } },
|
|
{ name: "delete_file", annotations: { destructiveHint: true } },
|
|
]);
|
|
const futureRefresh = await service.refreshCatalog(
|
|
connected.connectionId,
|
|
actor,
|
|
);
|
|
expect(futureRefresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "copy_file",
|
|
status: "quarantined",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
}),
|
|
]),
|
|
);
|
|
expect(futureRefresh.connection.config.quarantineNewEntries).toBe(true);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("rebuilds managed defaults when a removed connection is revived with its archived profile retained", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `drive-revival-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const connector = fakeGoogleWorkspaceConnector(
|
|
company.id,
|
|
userId,
|
|
"drive.write",
|
|
);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const driveDefinition = getConnectableAppDefinition("google-drive")!;
|
|
const previousOwnershipAvailability = driveDefinition.ownershipAvailability;
|
|
driveDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([
|
|
{ name: "search_files", annotations: { readOnlyHint: true } },
|
|
{ name: "create_file", annotations: { readOnlyHint: false } },
|
|
{ name: "delete_file", annotations: { destructiveHint: true } },
|
|
]);
|
|
|
|
try {
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-write",
|
|
grantKind: "user",
|
|
name: "Drive managed revival",
|
|
},
|
|
actor,
|
|
);
|
|
const firstStart = await service.startOAuth(
|
|
company.id,
|
|
first.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(firstStart.authorizationUrl).searchParams.get("state")!,
|
|
claimId: "drive-revival-first-claim",
|
|
actor,
|
|
});
|
|
const [retainedProfile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${first.connectionId}`));
|
|
await db.insert(toolMcpGateways).values({
|
|
companyId: company.id,
|
|
name: `Retained managed gateway ${randomUUID()}`,
|
|
slug: `retained-managed-${randomUUID()}`,
|
|
profileId: retainedProfile!.id,
|
|
status: "active",
|
|
});
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, retainedProfile!.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "archived" })]);
|
|
|
|
const revived = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "google-drive",
|
|
connectionMethodKey: "paperclip-write",
|
|
grantKind: "user",
|
|
name: "Drive managed revival",
|
|
},
|
|
actor,
|
|
);
|
|
expect(revived.connectionId).toBe(first.connectionId);
|
|
expect(revived.connection).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, retainedProfile!.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "archived" })]);
|
|
|
|
const revivedStart = await service.startOAuth(
|
|
company.id,
|
|
revived.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(revivedStart.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!,
|
|
claimId: "drive-revival-second-claim",
|
|
actor,
|
|
});
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
config: { quarantineNewEntries: true },
|
|
});
|
|
expect(completed.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
toolName: "search_files",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_file",
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_file",
|
|
status: "disabled",
|
|
}),
|
|
]),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, retainedProfile!.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "active" })]);
|
|
const revivedEntries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, retainedProfile!.id));
|
|
expect(
|
|
revivedEntries.map((entry) => entry.catalogEntryId).sort(),
|
|
).toEqual(
|
|
completed.catalog
|
|
.filter((entry) => entry.status === "active")
|
|
.map((entry) => entry.id)
|
|
.sort(),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, retainedProfile!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
}),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
),
|
|
).resolves.toEqual([]);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("marks a managed grant for reconnection when the provider rejects its refresh", async () => {
|
|
const company = await createCompany(db);
|
|
await createAgent(db, company.id);
|
|
const userId = `managed-refresh-rejected-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = fakeGmailConnector(company.id, userId);
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const definition = getConnectableAppDefinition("gmail")!;
|
|
const availability = definition.ownershipAvailability;
|
|
definition.ownershipAvailability = { ...availability, platform_shared: true };
|
|
mockToolsList([]);
|
|
try {
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "gmail", connectionMethodKey: "paperclip-draft", grantKind: "user", name: "Managed rejected refresh" }, actor);
|
|
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor });
|
|
await service.completePaperclipCloudConnectorCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "initial", actor });
|
|
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId)));
|
|
vi.mocked(connector.refresh).mockRejectedValueOnce(new PaperclipCloudConnectorError(
|
|
"Paperclip Cloud connector rejected the request (operation=refresh, status=409, reason=PROVIDER_REAUTHORIZATION_REQUIRED)",
|
|
"REAUTHORIZATION_REQUIRED",
|
|
409,
|
|
));
|
|
await expect(service.refreshOAuthGrantCredentials({ companyId: company.id, connectionId: connected.connectionId, grantId: grant.id, forceRefresh: true, actor }))
|
|
.rejects.toMatchObject({ status: 422, details: { code: "oauth_reauthorization_required" } });
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))[0].status).toBe("needs_reauthorization");
|
|
} finally {
|
|
definition.ownershipAvailability = availability;
|
|
}
|
|
});
|
|
|
|
it("orders managed reconnect with refresh and rejects superseded secret writes", async () => {
|
|
const company = await createCompany(db);
|
|
await createAgent(db, company.id);
|
|
const userId = `managed-refresh-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = fakeGmailConnector(company.id, userId);
|
|
const refreshDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const definition = getConnectableAppDefinition("gmail")!;
|
|
const availability = definition.ownershipAvailability;
|
|
definition.ownershipAvailability = { ...availability, platform_shared: true };
|
|
mockToolsList([]);
|
|
let allowRefresh!: () => void; let allowReconnect!: () => void;
|
|
try {
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "gmail", connectionMethodKey: "paperclip-draft", grantKind: "user", name: "Managed concurrent refresh" }, actor);
|
|
const start = () => service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor });
|
|
const initial = await start();
|
|
await service.completePaperclipCloudConnectorCallback({ state: new URL(initial.authorizationUrl).searchParams.get("state")!, claimId: "initial", actor });
|
|
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId)));
|
|
const accessId = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token")!.secretId;
|
|
const [before] = await db.select().from(companySecrets).where(eq(companySecrets.id, accessId));
|
|
const reconnect = await start();
|
|
const credentials = await connector.refresh({ subject: userId, companyId: company.id, profile: "gmail.draft", refreshToken: "fixture" });
|
|
let refreshing!: () => void;
|
|
const providerCalled = new Promise<void>(resolve => { refreshing = resolve; });
|
|
const mayRefresh = new Promise<void>(resolve => { allowRefresh = resolve; });
|
|
vi.mocked(connector.refresh).mockImplementation(async () => { refreshing(); await mayRefresh; return credentials; });
|
|
const [backend] = await refreshDb.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
const refresh = createTestToolAccessService(refreshDb, { paperclipCloudConnector: connector }).refreshOAuthGrantCredentials({ companyId: company.id, connectionId: connected.connectionId, grantId: grant.id, forceRefresh: true, actor });
|
|
const refreshResult = Promise.allSettled([refresh]);
|
|
await providerCalled;
|
|
let locked!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const mayReconnect = new Promise<void>(resolve => { allowReconnect = resolve; });
|
|
const callback = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
await tx.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)).for("update");
|
|
locked(); await mayReconnect;
|
|
return createTestToolAccessService(tx as unknown as Db, { paperclipCloudConnector: connector }).completePaperclipCloudConnectorCallback({ state: new URL(reconnect.authorizationUrl).searchParams.get("state")!, claimId: "reconnect", actor });
|
|
});
|
|
const callbackResult = Promise.allSettled([callback]);
|
|
await held;
|
|
allowRefresh();
|
|
try {
|
|
await vi.waitFor(async () => {
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${Number(backend.pid)}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
} finally { allowReconnect(); }
|
|
const [completion] = await callbackResult;
|
|
if (completion.status === "rejected") throw completion.reason;
|
|
const [refreshCompletion] = await refreshResult;
|
|
expect(refreshCompletion).toMatchObject({ status: "rejected", reason: { status: 409, details: { code: "oauth_refresh_superseded", retryable: true } } });
|
|
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, accessId)))[0].latestVersion).toBe(before.latestVersion + 1);
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))[0].status).toBe("active");
|
|
} finally {
|
|
allowRefresh?.(); allowReconnect?.();
|
|
definition.ownershipAvailability = availability;
|
|
await refreshDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
}
|
|
}, 20000);
|
|
|
|
it("keeps brokered OAuth state retryable until credentials are durably stored", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `gmail-retry-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, []);
|
|
const connector = fakeGmailConnector(company.id, userId);
|
|
vi.mocked(connector.claim).mockRejectedValueOnce(
|
|
new Error("temporary claim failure"),
|
|
);
|
|
const service = createTestToolAccessService(db, {
|
|
paperclipCloudConnector: connector,
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const gmailDefinition = getConnectableAppDefinition("gmail")!;
|
|
const previousOwnershipAvailability = gmailDefinition.ownershipAvailability;
|
|
gmailDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
mockToolsList([]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "gmail",
|
|
connectionMethodKey: "paperclip-draft",
|
|
grantKind: "user",
|
|
name: "Gmail retryable callback",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
|
|
await expect(
|
|
service.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "gmail-retry-claim",
|
|
actor,
|
|
}),
|
|
).rejects.toThrow("temporary claim failure");
|
|
await expect(service.peekOAuthState(state)).resolves.toMatchObject({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
subjectUserId: userId,
|
|
});
|
|
|
|
await expect(
|
|
service.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "gmail-retry-claim",
|
|
actor,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
connection: { status: "active", enabled: true },
|
|
});
|
|
await expect(service.peekOAuthState(state)).resolves.toBeNull();
|
|
expect(connector.claim).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ redemptionId: state }),
|
|
);
|
|
expect(connector.claim).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ redemptionId: state }),
|
|
);
|
|
} finally {
|
|
gmailDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("serializes brokered Gmail OAuth completion behind membership revocation", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `gmail-member-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, []);
|
|
const callbackDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const removalDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 });
|
|
const service = createTestToolAccessService(callbackDb, {
|
|
paperclipCloudConnector: fakeGmailConnector(company.id, userId),
|
|
});
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const gmailDefinition = getConnectableAppDefinition("gmail")!;
|
|
const previousOwnershipAvailability = gmailDefinition.ownershipAvailability;
|
|
gmailDefinition.ownershipAvailability = {
|
|
...previousOwnershipAvailability,
|
|
platform_shared: true,
|
|
};
|
|
let releaseRemoval!: () => void;
|
|
const removalMayCommit = new Promise<void>((resolve) => {
|
|
releaseRemoval = resolve;
|
|
});
|
|
let membershipLocked!: () => void;
|
|
const membershipIsLocked = new Promise<void>((resolve) => {
|
|
membershipLocked = resolve;
|
|
});
|
|
let removal: Promise<void> | null = null;
|
|
mockToolsList([]);
|
|
|
|
try {
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "gmail",
|
|
connectionMethodKey: "paperclip-draft",
|
|
grantKind: "user",
|
|
name: "Gmail concurrent revocation callback",
|
|
},
|
|
actor,
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri:
|
|
"https://paperclip.example/api/tools/oauth/cloud-connector/callback",
|
|
actor,
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
const beforeSecrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.companyId, company.id));
|
|
const beforeGrants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connected.connectionId));
|
|
|
|
removal = removalDb.transaction(async (tx) => {
|
|
await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
),
|
|
)
|
|
.for("update");
|
|
membershipLocked();
|
|
await removalMayCommit;
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
membershipRole: "viewer",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
),
|
|
);
|
|
});
|
|
|
|
await membershipIsLocked;
|
|
const completion = service
|
|
.completePaperclipCloudConnectorCallback({
|
|
state,
|
|
claimId: "gmail-claim",
|
|
actor,
|
|
})
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
);
|
|
|
|
expect(await waitForBlockedMembershipUpdate()).toBe(true);
|
|
releaseRemoval();
|
|
await removal;
|
|
const outcome = await completion;
|
|
expect(outcome.value).toBeNull();
|
|
expect(outcome.error).toMatchObject({
|
|
status: 403,
|
|
message: expect.stringContaining(
|
|
"membership no longer permits connection changes",
|
|
),
|
|
});
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection).toMatchObject({ status: "draft", enabled: false });
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.companyId, company.id)),
|
|
).resolves.toHaveLength(beforeSecrets.length);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connected.connectionId)),
|
|
).resolves.toEqual(beforeGrants);
|
|
} finally {
|
|
gmailDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
releaseRemoval();
|
|
await removal?.catch(() => undefined);
|
|
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
await removalDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
}
|
|
}, 15_000);
|
|
|
|
it("synchronizes shared OAuth credentials to the organization grant used by gateway calls", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [
|
|
"tools:use",
|
|
"tools:manage_connections",
|
|
]);
|
|
const agent = await createAgent(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: "Shared OAuth grant",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
},
|
|
);
|
|
|
|
let gatewayAuthorization: string | null = null;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
access_token: "shared-access-token",
|
|
refresh_token: "shared-refresh-token",
|
|
expires_in: 3600,
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
const payload = JSON.parse(String(init?.body ?? "{}")) as {
|
|
id?: string;
|
|
method?: string;
|
|
};
|
|
if (payload.method === "tools/call") {
|
|
gatewayAuthorization = new Headers(init?.headers).get(
|
|
"authorization",
|
|
);
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: payload.id,
|
|
result: { content: [{ type: "text", text: "channel details" }] },
|
|
});
|
|
}
|
|
if (payload.method === "tools/list") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: payload.id,
|
|
result: {
|
|
tools: [
|
|
{
|
|
name: "get_channel",
|
|
description: "Read a Slack channel.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { channel: { type: "string" } },
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: payload.id,
|
|
result: {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "Slack test", version: "1.0.0" },
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "shared-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
const [organizationGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
);
|
|
expect(organizationGrant).toMatchObject({ status: "active" });
|
|
expect(
|
|
organizationGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
).toEqual(
|
|
connection.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
);
|
|
expect(
|
|
organizationGrant.credentialSecretRefs
|
|
.map((ref) => ref.configPath)
|
|
.sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
|
|
await db.insert(toolPolicies).values({
|
|
companyId: company.id,
|
|
name: `Allow shared OAuth read ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { connectionId: connected.connectionId },
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
|
|
);
|
|
await request(app)
|
|
.post(`/api/tool-connections/${connected.connectionId}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "get_channel",
|
|
parameters: { channel: "general" },
|
|
})
|
|
.expect(200);
|
|
|
|
expect(gatewayAuthorization).toBe("Bearer shared-access-token");
|
|
});
|
|
|
|
// PAP-18538: Enterpret answers an `mcp:read` request with a token that carries
|
|
// `mcp:read mcp:write` but omits `scope` from the token response. Recording the request as
|
|
// the grant made the connection read back a read-only scope the provider never asserted.
|
|
describe("granted OAuth scope recording", () => {
|
|
async function connectSlackWithTokenScope(
|
|
tokenScope: string | null,
|
|
requestedScopes?: string[],
|
|
) {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [
|
|
"tools:use",
|
|
"tools:manage_connections",
|
|
]);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "slack", name: `Scope recording ${randomUUID()}` },
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
...(requestedScopes ? { scopes: requestedScopes } : {}),
|
|
},
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
access_token: "shared-access-token",
|
|
refresh_token: "shared-refresh-token",
|
|
expires_in: 3600,
|
|
// A provider that omits `scope` is telling us, per RFC 6749 §5.1, that the
|
|
// grant equals the request. Not every provider honours that.
|
|
...(tokenScope === null ? {} : { scope: tokenScope }),
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "shared-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
return {
|
|
authorizationScope: new URL(started.authorizationUrl).searchParams.get(
|
|
"scope",
|
|
),
|
|
recordedOAuth: (
|
|
connection.config.providerMetadata as
|
|
| { oauth?: Record<string, unknown> }
|
|
| undefined
|
|
)?.oauth,
|
|
};
|
|
}
|
|
|
|
it("marks a scope inferred from the request when the provider omits one", async () => {
|
|
const { authorizationScope, recordedOAuth } =
|
|
await connectSlackWithTokenScope(null, ["channels:read"]);
|
|
|
|
expect(authorizationScope).toBe("channels:read");
|
|
// No provider scope assertion means extra scopes are unknown, not absent.
|
|
// An empty comparison cannot establish the token's real capabilities.
|
|
expect(recordedOAuth).toMatchObject({
|
|
scope: null,
|
|
scopeSource: "requested_fallback",
|
|
unrequestedScopes: [],
|
|
});
|
|
});
|
|
|
|
it("records the provider's scope and flags anything it granted unasked", async () => {
|
|
const { recordedOAuth } = await connectSlackWithTokenScope(
|
|
"channels:read chat:write",
|
|
["channels:read"],
|
|
);
|
|
|
|
expect(recordedOAuth).toMatchObject({
|
|
scope: "channels:read chat:write",
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
});
|
|
|
|
it("treats a scope matching the request as a clean provider assertion", async () => {
|
|
const { recordedOAuth } = await connectSlackWithTokenScope(
|
|
"channels:read",
|
|
["channels:read"],
|
|
);
|
|
|
|
expect(recordedOAuth).toMatchObject({
|
|
scopeSource: "provider",
|
|
unrequestedScopes: [],
|
|
});
|
|
});
|
|
|
|
|
|
});
|
|
|
|
it.each(["creation", "reopening", "rollback"] as const)("signals native review recovery after OAuth card %s settles", async mode => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const connected = await createTestToolAccessService(db).connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack user auth" });
|
|
if (mode !== "creation") await db.insert(issueThreadInteractions).values({ companyId: company.id,
|
|
issueId: issue.id, kind: "request_confirmation", status: "accepted", payload: { version: 1, prompt: "Previous authorization" },
|
|
idempotencyKey: `connection-authorization:${connected.connectionId}:user-for-run`, sourceRunId: run.id });
|
|
const reassessment = vi.fn(), reviewResponse = vi.fn();
|
|
const unsubscribe = [subscribeDeliveryWork(db, DELIVERY_QUEUES.nativeStatusReassessment, reassessment),
|
|
subscribeDeliveryWork(db, DELIVERY_QUEUES.nativeReviewResponse, reviewResponse)];
|
|
const rollback = new Error("rollback OAuth reopening");
|
|
try {
|
|
const operation = db.transaction(async tx => {
|
|
const started = await createTestToolAccessService(tx as unknown as typeof db).startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "agent", actorId: agent.id, sessionId: run.id },
|
|
issueId: issue.id, subjectUserId: "user-for-run", scopes: ["channels:read"],
|
|
});
|
|
const [inside] = await tx.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
|
expect(inside.status).toBe("pending");
|
|
expect(inside.payload).toMatchObject({ target: { href: started.authorizationUrl } });
|
|
expect(reassessment).not.toHaveBeenCalled(); expect(reviewResponse).not.toHaveBeenCalled();
|
|
const [outside] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
|
expect(outside?.status).toBe(mode === "creation" ? undefined : "accepted");
|
|
if (mode === "rollback") throw rollback;
|
|
});
|
|
if (mode === "rollback") await expect(operation).rejects.toBe(rollback); else await operation;
|
|
|
|
|
|
expect(reassessment).toHaveBeenCalledTimes(1); expect(reviewResponse).toHaveBeenCalledTimes(1);
|
|
const [persisted] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
|
expect(persisted.status).toBe(mode === "rollback" ? "accepted" : "pending");
|
|
expect(await db.select().from(toolOauthStates).where(eq(toolOauthStates.issueId, issue.id)))
|
|
.toHaveLength(mode === "rollback" ? 0 : 1);
|
|
} finally { unsubscribe.forEach(stop => stop()); }
|
|
});
|
|
|
|
it("creates and resolves an agent-initiated user authorization grant card", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "workspace-owner", [
|
|
"tools:manage_connections",
|
|
]);
|
|
const agent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "slack",
|
|
name: "Slack user auth",
|
|
});
|
|
|
|
const workspaceStarted = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "workspace-owner" },
|
|
},
|
|
);
|
|
const workspaceState = new URL(
|
|
workspaceStarted.authorizationUrl,
|
|
).searchParams.get("state")!;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
const userAuthorization =
|
|
body.get("code") === "user-authorization-code";
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
access_token: userAuthorization
|
|
? "user-access-token"
|
|
: "workspace-access-token",
|
|
refresh_token: userAuthorization
|
|
? "user-refresh-token"
|
|
: "workspace-refresh-token",
|
|
expires_in: 3600,
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: workspaceState,
|
|
code: "workspace-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "workspace-owner" },
|
|
});
|
|
const [workspaceConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
const workspaceSecretIds = workspaceConnection.credentialSecretRefs
|
|
.map((ref) => ref.secretId)
|
|
.sort();
|
|
|
|
const started = await service.startAuthorizationForAgent({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
subjectUserId: "user-for-run",
|
|
scopes: ["channels:read"],
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
});
|
|
const authorizationUrl = new URL(started.authorizationUrl);
|
|
expect(authorizationUrl.searchParams.get("scope")).toBe("channels:read");
|
|
|
|
const [state] = await db.select().from(toolOauthStates);
|
|
expect(state).toMatchObject({
|
|
subjectUserId: "user-for-run",
|
|
issueId: issue.id,
|
|
requestedScopes: ["channels:read"],
|
|
});
|
|
const [interaction] = await db.select().from(issueThreadInteractions);
|
|
expect(interaction).toMatchObject({
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
title: "Connect your Slack to continue",
|
|
});
|
|
expect(interaction.payload).toMatchObject({
|
|
target: { href: started.authorizationUrl },
|
|
});
|
|
|
|
await service.completeOAuthCallback({
|
|
state: state.state,
|
|
code: "user-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "user-for-run" },
|
|
});
|
|
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, "user-for-run"),
|
|
),
|
|
);
|
|
expect(grant).toMatchObject({ kind: "user", status: "active" });
|
|
// PAP-18538: this token response carries no `scope`, so the recorded scope is inferred
|
|
// from the request rather than asserted by the provider. Keep the distinction visible.
|
|
expect(grant.providerTenant.oauth).toMatchObject({
|
|
scopes: ["channels:read"],
|
|
scopeSource: "requested_fallback",
|
|
unrequestedScopes: [],
|
|
});
|
|
expect(
|
|
grant.credentialSecretRefs.map((ref) => ref.configPath).sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
expect(
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
).not.toEqual(workspaceSecretIds);
|
|
const personalSecrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
inArray(
|
|
companySecrets.id,
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
);
|
|
expect(personalSecrets).toHaveLength(2);
|
|
expect(personalSecrets).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ scope: "user", ownerUserId: "user-for-run" }),
|
|
]),
|
|
);
|
|
expect(
|
|
personalSecrets.every((secret) => secret.userSecretDefinitionId !== null),
|
|
).toBe(true);
|
|
const [unchangedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(
|
|
unchangedConnection.credentialSecretRefs
|
|
.map((ref) => ref.secretId)
|
|
.sort(),
|
|
).toEqual(workspaceSecretIds);
|
|
const [resolved] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interaction.id));
|
|
expect(resolved).toMatchObject({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
});
|
|
|
|
const versionCountBeforeAccessRevocation = (
|
|
await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
inArray(
|
|
companySecretVersions.secretId,
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
)
|
|
).length;
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
await expect(
|
|
service.startAuthorizationForAgent({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
subjectUserId: "user-for-run",
|
|
scopes: ["channels:read"],
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "member" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
const retry = await service.startAuthorizationForAgent({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
subjectUserId: "user-for-run",
|
|
scopes: ["channels:read"],
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
});
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "viewer" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
await expect(
|
|
service.completeOAuthCallback({
|
|
state: new URL(retry.authorizationUrl).searchParams.get("state")!,
|
|
code: "user-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "user-for-run" },
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
inArray(
|
|
companySecretVersions.secretId,
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
)
|
|
).length,
|
|
).toBe(versionCountBeforeAccessRevocation);
|
|
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ membershipRole: "member" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
const suspendedRetry = await service.startAuthorizationForAgent({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
subjectUserId: "user-for-run",
|
|
scopes: ["channels:read"],
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
});
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "suspended" })
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, company.id),
|
|
eq(companyMemberships.principalId, "user-for-run"),
|
|
),
|
|
);
|
|
await expect(
|
|
service.completeOAuthCallback({
|
|
state: new URL(suspendedRetry.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!,
|
|
code: "user-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "user-for-run" },
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
inArray(
|
|
companySecretVersions.secretId,
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
)
|
|
).length,
|
|
).toBe(versionCountBeforeAccessRevocation);
|
|
});
|
|
|
|
it.each(["page", "task"] as const)(
|
|
"activates and discovers actions for a fresh personal OAuth callback from %s without widening task access",
|
|
async (host) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const agent = await createAgent(db, company.id);
|
|
const otherAgent = await createAgent(db, company.id);
|
|
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
|
const interaction =
|
|
host === "task"
|
|
? (
|
|
await db
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
sourceRunId: run.id,
|
|
kind: "connection_intent",
|
|
status: "pending",
|
|
createdByAgentId: agent.id,
|
|
addresseeUserId: userId,
|
|
payload: {
|
|
version: 1,
|
|
serviceSlug: "slack",
|
|
serviceName: "Slack",
|
|
requestingAgentId: agent.id,
|
|
requestingAgentName: agent.name,
|
|
phase: "authorizing",
|
|
},
|
|
})
|
|
.returning()
|
|
)[0]
|
|
: undefined;
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: "Personal OAuth callback",
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
subjectUserId: userId,
|
|
interactionId: interaction?.id,
|
|
},
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const code = (init?.body as URLSearchParams).get("code");
|
|
expect(["personal-code", "personal-reconnect-code"]).toContain(code);
|
|
const reconnecting = code === "personal-reconnect-code";
|
|
return mcpHttpResponse(oauthToken(reconnecting
|
|
? "personal-access-token-2"
|
|
: "personal-access-token", reconnecting
|
|
? "personal-refresh-token-2"
|
|
: "personal-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: expect.stringMatching(
|
|
/^Bearer personal-access-token(?:-2)?$/,
|
|
),
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "search_messages",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{ name: "send_message", annotations: { readOnlyHint: false } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const completed = await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "personal-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
|
|
expect(completed.connection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: "per_user",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(completed.catalog.map((entry) => entry.toolName).sort()).toEqual([
|
|
"search_messages",
|
|
"send_message",
|
|
]);
|
|
expect(completed.connection.credentialSecretRefs).toEqual([]);
|
|
const [callbackProfile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, callbackProfile!.id)),
|
|
).resolves.toHaveLength(2);
|
|
const searchMessagesEntry = completed.catalog.find(
|
|
(entry) => entry.toolName === "search_messages",
|
|
)!;
|
|
const sendMessageEntry = completed.catalog.find(
|
|
(entry) => entry.toolName === "send_message",
|
|
)!;
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
),
|
|
).resolves.toEqual([]);
|
|
const callbackPolicy = toolAccessPolicyService(db);
|
|
const decide = (
|
|
entry: (typeof completed.catalog)[number],
|
|
agentId = agent.id,
|
|
) =>
|
|
callbackPolicy.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agentId, agentId },
|
|
request: {
|
|
connectionId: connected.connectionId,
|
|
catalogEntryId: entry.id,
|
|
toolName: entry.toolName,
|
|
arguments: {},
|
|
},
|
|
});
|
|
for (const entry of [searchMessagesEntry, sendMessageEntry]) {
|
|
await expect(decide(entry)).resolves.toMatchObject(
|
|
host === "task"
|
|
? { decision: "deny" }
|
|
: { decision: "allow", reasonCode: "allow_profile" },
|
|
);
|
|
}
|
|
if (host === "task") {
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, callbackProfile!.id)),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interaction!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ status: "pending", result: null }),
|
|
]);
|
|
}
|
|
const [personalGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
expect(personalGrant).toMatchObject({ status: "active", kind: "user" });
|
|
expect(
|
|
personalGrant.credentialSecretRefs.map((ref) => ref.configPath).sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
|
|
const finished = await service.finalizeOAuthAccess(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
host === "task" ? agent.id : undefined,
|
|
);
|
|
expect(finished.profileEntries).toHaveLength(2);
|
|
expect(finished.profileBindings).toEqual([
|
|
expect.objectContaining(
|
|
host === "task"
|
|
? { targetType: "agent", targetId: agent.id }
|
|
: { targetType: "company", targetId: company.id },
|
|
),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "company"),
|
|
),
|
|
),
|
|
).resolves.toHaveLength(host === "task" ? 0 : 1);
|
|
if (host === "task") {
|
|
await expect(decide(searchMessagesEntry)).resolves.toMatchObject({
|
|
decision: "allow",
|
|
});
|
|
await expect(
|
|
decide(searchMessagesEntry, otherAgent.id),
|
|
).resolves.toMatchObject({ decision: "deny" });
|
|
// Granting a second requester and retrying the first must preserve both installs.
|
|
for (const requestingAgentId of [otherAgent.id, agent.id]) {
|
|
await service.finalizeOAuthAccess(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
requestingAgentId,
|
|
);
|
|
}
|
|
const bindings = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, callbackProfile!.id));
|
|
expect(
|
|
bindings.map(({ targetType, targetId }) => ({
|
|
targetType,
|
|
targetId,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining(
|
|
[agent.id, otherAgent.id].map((targetId) => ({
|
|
targetType: "agent",
|
|
targetId,
|
|
})),
|
|
),
|
|
);
|
|
expect(bindings).toHaveLength(2);
|
|
const installs = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
);
|
|
expect(
|
|
installs.map(({ targetType, targetId }) => ({
|
|
targetType,
|
|
targetId,
|
|
})),
|
|
).toEqual(
|
|
expect.arrayContaining(
|
|
[agent.id, otherAgent.id].map((targetId) => ({
|
|
targetType: "agent",
|
|
targetId,
|
|
})),
|
|
),
|
|
);
|
|
expect(installs).toHaveLength(2);
|
|
}
|
|
await expect(
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: `different-user-${randomUUID()}`,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ status: 403 });
|
|
|
|
const personalSecretIds = personalGrant.credentialSecretRefs
|
|
.map((ref) => ref.secretId)
|
|
.sort();
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "revoked",
|
|
credentialSecretRefs: [],
|
|
revokedAt: new Date(),
|
|
revokedByUserId: userId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(connectionGrants.id, personalGrant.id));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "draft",
|
|
enabled: false,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
const reconnect = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
interactionId: interaction?.id,
|
|
},
|
|
);
|
|
await expect(
|
|
service.peekOAuthState(
|
|
new URL(reconnect.authorizationUrl).searchParams.get("state")!,
|
|
),
|
|
).resolves.toMatchObject({ subjectUserId: userId });
|
|
|
|
await expect(
|
|
service.completeOAuthCallback({
|
|
state: new URL(reconnect.authorizationUrl).searchParams.get("state")!,
|
|
code: "personal-reconnect-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
}),
|
|
).resolves.toMatchObject({
|
|
connection: { status: "active", enabled: true },
|
|
});
|
|
const [revivedGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, personalGrant.id));
|
|
expect(revivedGrant).toMatchObject({ status: "active" });
|
|
expect(
|
|
revivedGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
).toEqual(personalSecretIds);
|
|
const revivedSecrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(inArray(companySecrets.id, personalSecretIds));
|
|
expect(revivedSecrets).toHaveLength(2);
|
|
expect(revivedSecrets.every((secret) => secret.latestVersion === 2)).toBe(
|
|
true,
|
|
);
|
|
if (host === "task") {
|
|
const installs = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
);
|
|
expect(installs).toHaveLength(2);
|
|
expect(
|
|
installs.every((install) => install.targetType === "agent"),
|
|
).toBe(true);
|
|
for (const allowedAgentId of [agent.id, otherAgent.id]) {
|
|
await expect(
|
|
decide(searchMessagesEntry, allowedAgentId),
|
|
).resolves.toMatchObject({ decision: "allow" });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each((["personal", "organization", "managed"] as const).flatMap(mode =>
|
|
(["app finishing", "new tool discovery"] as const).map(change => ({ mode, change }))))("orders $mode OAuth persistence with concurrent $change", async ({ mode, change }) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
|
const company = await createCompany(db);
|
|
const userId = `catalog-oauth-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connector = mode === "managed" ? fakeGmailConnector(company.id, userId) : undefined;
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const definition = getConnectableAppDefinition(mode === "managed" ? "gmail" : "slack")!;
|
|
const availability = definition.ownershipAvailability;
|
|
if (mode === "managed") definition.ownershipAvailability = { ...availability, platform_shared: true };
|
|
let discoverNewTool = false;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
if (String(url) === "https://slack.com/api/oauth.v2.access") return mcpHttpResponse(oauthToken("fixture-access-token", "fixture-refresh-token"));
|
|
const body = JSON.parse(String(init?.body));
|
|
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
|
return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: body.method === "initialize" ? { protocolVersion: "2025-06-18" }
|
|
: { tools: [{ name: "search_messages", annotations: { readOnlyHint: true } }, ...(discoverNewTool ? [{ name: "new_search", annotations: { readOnlyHint: true } }] : [])] } });
|
|
});
|
|
let release!: () => void;
|
|
let transactionSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
let completed: Promise<PromiseSettledResult<unknown>[]> | undefined;
|
|
let blocker: Promise<unknown> | undefined;
|
|
try {
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: mode === "managed" ? "gmail" : "slack", ...(mode === "managed" ? { connectionMethodKey: "paperclip-draft" } : {}), name: "Concurrent catalog OAuth", grantKind: mode === "organization" ? "organization" : "user" }, actor);
|
|
const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback";
|
|
const start = () => service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, ...(mode === "organization" ? {} : { subjectUserId: userId }) });
|
|
const complete = (state: string, claim: string) => mode === "managed"
|
|
? service.completePaperclipCloudConnectorCallback({ state, claimId: claim, actor })
|
|
: service.completeOAuthCallback({ state, code: claim, redirectUri, actor });
|
|
const initial = await start();
|
|
await complete(new URL(initial.authorizationUrl).searchParams.get("state")!, "initial");
|
|
const reconnect = await start();
|
|
const state = new URL(reconnect.authorizationUrl).searchParams.get("state")!;
|
|
const [stateRow] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state));
|
|
expect(stateRow.subjectUserId).toBe(mode === "organization" ? null : userId);
|
|
discoverNewTool = change === "new tool discovery";
|
|
await db.execute(sql`CREATE OR REPLACE FUNCTION catalog_oauth_fixture_pause() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.id::text, 0)); RETURN NEW; END $$`);
|
|
await db.execute(sql.raw(`CREATE TRIGGER catalog_oauth_fixture_pause AFTER UPDATE ON tool_connections FOR EACH ROW WHEN (NEW.id = '${connected.connectionId}'::uuid) EXECUTE FUNCTION catalog_oauth_fixture_pause()`));
|
|
let locked!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
blocker = db.transaction(async tx => {
|
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${connected.connectionId}, 0))`);
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const pids: number[] = [];
|
|
const transaction = db.transaction.bind(db);
|
|
transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [backend] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(backend.pid));
|
|
return operation(tx);
|
|
}, config)) as typeof db.transaction);
|
|
const waitForLock = (index: number) => vi.waitFor(async () => {
|
|
expect(pids.length).toBeGreaterThan(index);
|
|
const [activity] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pids[index]}`);
|
|
expect(activity.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
const callback = complete(state, "reconnect");
|
|
const callbackResult = Promise.allSettled([callback]);
|
|
await waitForLock(0);
|
|
const publication = change === "new tool discovery" ? service.refreshCatalog(connected.connectionId, actor, { credentialHeaders: { Authorization: "Bearer fixture-catalog-token" } })
|
|
: service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" }, actor);
|
|
completed = Promise.allSettled([callback, publication]);
|
|
await waitForLock(1);
|
|
release(); await blocker;
|
|
const results = await completed;
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
expect((await callbackResult)[0].status).toBe("fulfilled");
|
|
expect(await service.getConnection(connected.connectionId, company.id)).toMatchObject({ status: "active" });
|
|
if (change === "new tool discovery") expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId))).toEqual(expect.arrayContaining([expect.objectContaining({ toolName: "new_search" })]));
|
|
else expect(await db.select().from(toolProfiles).where(and(eq(toolProfiles.companyId, company.id), eq(toolProfiles.profileKey, `app:${connected.connectionId}`)))).toHaveLength(1);
|
|
} finally {
|
|
release?.(); await blocker; await completed;
|
|
transactionSpy?.mockRestore();
|
|
await db.execute(sql`DROP TRIGGER IF EXISTS catalog_oauth_fixture_pause ON tool_connections; DROP FUNCTION IF EXISTS catalog_oauth_fixture_pause()`);
|
|
definition.ownershipAvailability = availability;
|
|
}
|
|
}, 20000);
|
|
|
|
it.each(["sharing", "settings", "application"] as const)("orders personal OAuth reconnect credential rotation with concurrent %s", async change => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
|
const company = await createCompany(db);
|
|
await createAgent(db, company.id);
|
|
const userId = `oauth-sharer-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Concurrent sharing", grantKind: "user" }, actor);
|
|
const connection = await service.getConnection(connected.connectionId, company.id);
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async url => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") return mcpHttpResponse(oauthToken("fixture-access-token", "fixture-refresh-token"));
|
|
if (href === "https://mcp.slack.com/mcp") return mcpHttpResponse(catalogResponse([{ name: "search_messages", annotations: { readOnlyHint: true } }]));
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
const start = () => service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, subjectUserId: userId });
|
|
const initial = await start();
|
|
await service.completeOAuthCallback({ state: new URL(initial.authorizationUrl).searchParams.get("state")!, code: "initial", redirectUri, actor });
|
|
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId)));
|
|
const reconnect = await start();
|
|
let locked!: () => void; let resume!: () => void; let started!: (pid: number) => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const mayShare = new Promise<void>(resolve => { resume = resolve; });
|
|
const reconnectStarted = new Promise<number>(resolve => { started = resolve; });
|
|
const sharing = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
if (change === "sharing") await tx.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)).for("update");
|
|
else if (change === "application") await tx.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId)).for("update");
|
|
else await tx.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)).for("update");
|
|
locked(); await mayShare;
|
|
const writer = createTestToolAccessService(tx as unknown as Db);
|
|
return change === "sharing"
|
|
? writer.finalizeOAuthAccess(company.id, connected.connectionId, { grantKind: "organization" }, actor)
|
|
: change === "application"
|
|
? writer.updateApplication(connection.applicationId, { name: "Application saved concurrently" })
|
|
: writer.updateConnection(connected.connectionId, { name: "Settings saved concurrently" });
|
|
});
|
|
await held;
|
|
const callback = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
started(Number(row.pid));
|
|
return createTestToolAccessService(tx as unknown as Db).completeOAuthCallback({ state: new URL(reconnect.authorizationUrl).searchParams.get("state")!, code: "reconnect", redirectUri, actor });
|
|
});
|
|
const completed = Promise.allSettled([sharing, callback]);
|
|
try {
|
|
const pid = await reconnectStarted;
|
|
await vi.waitFor(async () => {
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
} finally { resume(); }
|
|
const results = await completed;
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
expect(results.map(result => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
expect(await service.getConnection(connected.connectionId, company.id)).toMatchObject({ credentialPolicy: "per_user", status: "active" });
|
|
if (change === "sharing") expect((await service.listConnectionGrants(connected.connectionId, company.id)).grants).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "organization", status: "active" })]));
|
|
else if (change === "application") {
|
|
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId));
|
|
expect(application.name).toBe("Application saved concurrently");
|
|
} else expect((await service.getConnection(connected.connectionId, company.id)).name).toBe("Settings saved concurrently");
|
|
}, 15000);
|
|
|
|
it.each(["app finishing", "new tool discovery"] as const)("orders %s with OAuth reconnect before either locks the connection", async change => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-sharer-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Concurrent sharing", grantKind: "user" }, actor);
|
|
const connection = await service.getConnection(connected.connectionId, company.id);
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
let discoverNewTool = false;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async url => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") return mcpHttpResponse(oauthToken("fixture-access-token", "fixture-refresh-token"));
|
|
if (href === "https://mcp.slack.com/mcp") return mcpHttpResponse(catalogResponse([{ name: "search_messages", annotations: { readOnlyHint: true } }, ...(discoverNewTool ? [{ name: "new_search", annotations: { readOnlyHint: true } }] : [])]));
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
const start = () => service.startOAuth(company.id, connected.connectionId, { redirectUri, actor, subjectUserId: userId });
|
|
const initial = await start();
|
|
await service.completeOAuthCallback({ state: new URL(initial.authorizationUrl).searchParams.get("state")!, code: "initial", redirectUri, actor });
|
|
const reconnect = await start();
|
|
discoverNewTool = change === "new tool discovery";
|
|
let locked!: () => void; let release!: () => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const resume = new Promise<void>(resolve => { release = resolve; });
|
|
const blocker = db.transaction(async tx => {
|
|
await tx.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId)).for("update");
|
|
locked(); await resume;
|
|
});
|
|
await held;
|
|
const pids: number[] = [];
|
|
const transaction = db.transaction.bind(db);
|
|
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((async (operation, config) => transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
pids.push(Number(row.pid));
|
|
return operation(tx);
|
|
}, config)) as typeof db.transaction);
|
|
const run = (work: (service: ReturnType<typeof createTestToolAccessService>) => Promise<unknown>) => work(createTestToolAccessService(db));
|
|
const waitForLock = (index: number) => vi.waitFor(async () => {
|
|
expect(pids.length).toBeGreaterThan(index);
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pids[index]}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
const callback = run(writer => writer.completeOAuthCallback({ state: new URL(reconnect.authorizationUrl).searchParams.get("state")!, code: "reconnect", redirectUri, actor }));
|
|
let finishing: Promise<unknown> | undefined;
|
|
try {
|
|
await waitForLock(0);
|
|
finishing = run(writer => change === "new tool discovery" ? writer.refreshCatalog(connected.connectionId) : writer.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents" }, actor));
|
|
await waitForLock(1);
|
|
} finally { release(); await blocker; }
|
|
const results = await Promise.allSettled([callback, finishing]);
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
transactionSpy.mockRestore();
|
|
expect(results.map(result => result.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
expect(await service.getConnection(connected.connectionId, company.id)).toMatchObject({ credentialPolicy: "per_user", status: "active" });
|
|
if (change === "new tool discovery") expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId))).toEqual(expect.arrayContaining([expect.objectContaining({ toolName: "new_search" })]));
|
|
else expect(await db.select().from(toolProfiles).where(and(eq(toolProfiles.companyId, company.id), eq(toolProfiles.profileKey, `app:${connected.connectionId}`)))).toHaveLength(1);
|
|
}, 15000);
|
|
|
|
it("promotes a personal OAuth identity only after Everyone in the company is chosen", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-sharer-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: "Shared after OAuth",
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
subjectUserId: userId,
|
|
},
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return mcpHttpResponse(oauthToken("share-access-token", "share-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "search_messages", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "share-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const [beforeGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
const personalSecretIds = beforeGrant.credentialSecretRefs.map(
|
|
(ref) => ref.secretId,
|
|
);
|
|
|
|
await service.finalizeOAuthAccess(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
grantKind: "organization",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
|
|
const promotedConnection = await service.getConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
expect(promotedConnection).toMatchObject({
|
|
credentialPolicy: "shared",
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
expect(
|
|
promotedConnection.credentialSecretRefs
|
|
.map((ref) => ref.configPath)
|
|
.sort(),
|
|
).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
|
const { grants } = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
expect(grants).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "organization",
|
|
isDefault: true,
|
|
status: "active",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
status: "revoked",
|
|
credentialSecretRefs: [],
|
|
}),
|
|
]),
|
|
);
|
|
const organizationGrant = grants.find(
|
|
(grant) => grant.kind === "organization",
|
|
)!;
|
|
expect(
|
|
organizationGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
).toEqual(
|
|
promotedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort(),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(connectionGrantMembers)
|
|
.where(eq(connectionGrantMembers.grantId, organizationGrant.id)),
|
|
).resolves.toHaveLength(0);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(inArray(companySecrets.id, personalSecretIds)),
|
|
).resolves.toHaveLength(0);
|
|
const promotedSecrets = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
inArray(
|
|
companySecrets.id,
|
|
promotedConnection.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
);
|
|
expect(
|
|
promotedSecrets.every(
|
|
(secret) => secret.scope === "company" && secret.ownerUserId === null,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("refreshes an expired personal OAuth grant during health checks without moving its tokens onto the connection", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-refresh-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: "Personal OAuth refresh",
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
subjectUserId: userId,
|
|
},
|
|
);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
if (body.get("grant_type") === "refresh_token") {
|
|
expect(body.get("refresh_token")).toBe("personal-refresh-token");
|
|
return mcpHttpResponse(oauthToken("refreshed-personal-access-token", "rotated-personal-refresh-token"));
|
|
}
|
|
return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "search_messages",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "personal-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...(grant.providerTenant?.oauth ?? {}),
|
|
accessTokenExpiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
fetchMock.mockClear();
|
|
|
|
const health = await service.checkHealth(connected.connectionId, {
|
|
actorType: "system",
|
|
actorId: "health-check",
|
|
});
|
|
const [refreshed] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
|
|
expect(health.connection.healthStatus).toBe("ok");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(refreshed).toMatchObject({
|
|
status: "active",
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
});
|
|
expect(
|
|
Date.parse(refreshed.providerTenant?.oauth?.accessTokenExpiresAt ?? ""),
|
|
).toBeGreaterThan(Date.now());
|
|
expect(refreshed.providerTenant?.oauth?.refreshLease).toBeUndefined();
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.credentialSecretRefs).toEqual([]);
|
|
const versions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(
|
|
inArray(
|
|
companySecretVersions.secretId,
|
|
refreshed.credentialSecretRefs.map((ref) => ref.secretId),
|
|
),
|
|
);
|
|
expect(versions).toHaveLength(4);
|
|
expect(
|
|
versions.filter((version) => version.status === "current"),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
// A refresh has no fresh authorization request, so the standing grant is the baseline the
|
|
// provider's response is judged against. A provider that widens at refresh must not be able
|
|
// to do it quietly.
|
|
it("flags a scope the provider widens at refresh time", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-refresh-scope-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: "Refresh scope widening",
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
subjectUserId: userId,
|
|
scopes: ["channels:read"],
|
|
},
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
if (body.get("grant_type") === "refresh_token") {
|
|
return mcpHttpResponse({
|
|
ok: true,
|
|
access_token: "refreshed-access-token",
|
|
refresh_token: "rotated-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
// Wider than the grant being refreshed.
|
|
scope: "channels:read chat:write",
|
|
});
|
|
}
|
|
return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "personal-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
expect(grant.providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read"],
|
|
scopeSource: "requested_fallback",
|
|
});
|
|
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...(grant.providerTenant?.oauth ?? {}),
|
|
accessTokenExpiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
|
|
await service.checkHealth(connected.connectionId, {
|
|
actorType: "system",
|
|
actorId: "health-check",
|
|
});
|
|
|
|
const [refreshed] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
expect(refreshed.providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read", "chat:write"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
});
|
|
|
|
// A generic MCP connection can request less than discovery advertised. If the connection
|
|
// records discovery's universe as its scope set, a later refresh compares the provider's
|
|
// response against the wider list and an asserted extra scope stops looking unrequested.
|
|
it("keeps the narrowed request as the refresh baseline for a generic MCP connection", async () => {
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_NARROW_EXAMPLE_TEST_CLIENT_ID",
|
|
"narrow-client-id",
|
|
);
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_NARROW_EXAMPLE_TEST_CLIENT_SECRET",
|
|
"narrow-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `narrow-scope-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
let refreshScope: string | null = null;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://narrow.example.test/mcp") {
|
|
const authorization = (init?.headers as Record<string, string>)?.[
|
|
"Authorization"
|
|
];
|
|
if (!authorization) {
|
|
return {
|
|
ok: false,
|
|
status: 401,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "www-authenticate"
|
|
? 'Bearer resource_metadata="https://narrow.example.test/.well-known/oauth-protected-resource"'
|
|
: null,
|
|
},
|
|
text: async () => "",
|
|
json: async () => ({}),
|
|
} as Response;
|
|
}
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
if (
|
|
href ===
|
|
"https://narrow.example.test/.well-known/oauth-protected-resource"
|
|
) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
authorization_endpoint: "https://narrow.example.test/oauth/authorize",
|
|
token_endpoint: "https://narrow.example.test/oauth/token",
|
|
// Discovery advertises both; Paperclip will ask for only one.
|
|
scopes_supported: ["tools.read", "tools.write"],
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://narrow.example.test/oauth/token") {
|
|
const body = init?.body as URLSearchParams;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
access_token: `access-${randomUUID()}`,
|
|
refresh_token: `refresh-${randomUUID()}`,
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
...(body.get("grant_type") === "refresh_token" && refreshScope
|
|
? { scope: refreshScope }
|
|
: {}),
|
|
}),
|
|
} as Response;
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: `Narrow generic ${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
config: { url: "https://narrow.example.test/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const started = await service.startOAuth(company.id, connection.id, {
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
scopes: ["tools.read"],
|
|
});
|
|
expect(new URL(started.authorizationUrl).searchParams.get("scope")).toBe(
|
|
"tools.read",
|
|
);
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "narrow-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
|
|
const [afterCallback] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
// Not ["tools.read", "tools.write"] — the connection records what was asked for.
|
|
expect(
|
|
(afterCallback.config.oauth as { scopes?: string[] } | undefined)?.scopes,
|
|
).toEqual(["tools.read"]);
|
|
|
|
refreshScope = "tools.read tools.write";
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
await service.refreshOAuthGrantCredentials({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
forceRefresh: true,
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
|
|
const [refreshed] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
expect(refreshed.providerTenant?.oauth).toMatchObject({
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["tools.write"],
|
|
});
|
|
});
|
|
|
|
// Two users can authorize the same connection with different scopes. The connection-level
|
|
// scope list is whichever callback ran last, so it is the wrong refresh baseline for at
|
|
// least one of them — the baseline has to live on the grant.
|
|
it("gives each personal grant its own refresh scope baseline", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const wideUser = `oauth-wide-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, wideUser, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "slack",
|
|
name: `Per-grant baseline ${randomUUID()}`,
|
|
grantKind: "user",
|
|
},
|
|
{ actorType: "user", actorId: wideUser },
|
|
);
|
|
let tokenScope: string | null = null;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return mcpHttpResponse({
|
|
ok: true,
|
|
access_token: `access-${randomUUID()}`,
|
|
refresh_token: `refresh-${randomUUID()}`,
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
...(tokenScope === null ? {} : { scope: tokenScope }),
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
const authorize = async (userId: string, scopes: string[]) => {
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
subjectUserId: userId,
|
|
scopes,
|
|
},
|
|
);
|
|
tokenScope = scopes.join(" ");
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: `code-${randomUUID()}`,
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
),
|
|
);
|
|
return grant;
|
|
};
|
|
|
|
const wideGrant = await authorize(wideUser, ["channels:read", "chat:write"]);
|
|
expect(wideGrant.providerTenant?.oauth).toMatchObject({
|
|
requestedScopes: ["channels:read", "chat:write"],
|
|
});
|
|
|
|
// Stand in for a second, narrower authorization on the same connection: the
|
|
// connection-level scope list is whichever callback ran last, so it drifts away from
|
|
// what this grant asked for. Written directly because a gallery personal connection
|
|
// pins one identity; a `per_user` connection reaches the same state through a real
|
|
// second callback.
|
|
const [beforeDrift] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...beforeDrift.config,
|
|
oauth: {
|
|
...(beforeDrift.config.oauth as Record<string, unknown>),
|
|
scopes: ["channels:read"],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
|
|
// Refresh this grant. The provider asserts exactly what this grant asked for, so
|
|
// nothing is unrequested — even though the connection now records only
|
|
// `channels:read`.
|
|
tokenScope = "channels:read chat:write";
|
|
await service.refreshOAuthGrantCredentials({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
grantId: wideGrant.id,
|
|
forceRefresh: true,
|
|
actor: { actorType: "user", actorId: wideUser },
|
|
});
|
|
|
|
const [refreshedWide] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, wideGrant.id));
|
|
expect(refreshedWide.providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read", "chat:write"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: [],
|
|
});
|
|
});
|
|
|
|
// PAP-18538: an over-grant recorded at authorization has to survive the ordinary life of the
|
|
// connection. A refresh is the routine event, and the shared organization identity is the
|
|
// shape the grants API and the Permissions UI read from.
|
|
describe("over-grant provenance on an organization grant", () => {
|
|
async function connectSharedSlack(authorizationScope: string | null) {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const userId = `oauth-shared-scope-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "slack", name: `Shared scope ${randomUUID()}` },
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
scopes: ["channels:read"],
|
|
},
|
|
);
|
|
const slackToken = (scope: string | null) =>
|
|
mcpHttpResponse({
|
|
ok: true,
|
|
access_token: `access-${randomUUID()}`,
|
|
refresh_token: `refresh-${randomUUID()}`,
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
...(scope === null ? {} : { scope }),
|
|
});
|
|
let refreshScope: string | null = null;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
return body.get("grant_type") === "refresh_token"
|
|
? slackToken(refreshScope)
|
|
: slackToken(authorizationScope);
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!,
|
|
code: "shared-authorization-code",
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
const readGrant = async () => {
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connected.connectionId),
|
|
eq(connectionGrants.kind, "organization"),
|
|
),
|
|
);
|
|
return grant;
|
|
};
|
|
const readConnectionOAuth = async () => {
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
return (
|
|
connection.config.providerMetadata as {
|
|
oauth?: Record<string, unknown>;
|
|
}
|
|
)?.oauth;
|
|
};
|
|
return {
|
|
service,
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
actor: { actorType: "user" as const, actorId: userId },
|
|
readGrant,
|
|
readConnectionOAuth,
|
|
refreshWithScope: async (scope: string | null) => {
|
|
refreshScope = scope;
|
|
const grant = await readGrant();
|
|
await service.refreshOAuthGrantCredentials({
|
|
companyId: company.id,
|
|
connectionId: connected.connectionId,
|
|
grantId: grant.id,
|
|
forceRefresh: true,
|
|
actor: { actorType: "user", actorId: userId },
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
it("keeps the over-grant warning when a refresh asserts no scope", async () => {
|
|
const lane = await connectSharedSlack("channels:read chat:write");
|
|
|
|
expect((await lane.readGrant()).providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read", "chat:write"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
|
|
await lane.refreshWithScope(null);
|
|
|
|
// The refresh response said nothing about scope. That is not evidence the provider took
|
|
// `chat:write` away, so the warning stays on both the grant and the connection.
|
|
expect((await lane.readGrant()).providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read", "chat:write"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
expect(await lane.readConnectionOAuth()).toMatchObject({
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
});
|
|
|
|
it("keeps the warning when a refresh re-asserts the same widened scope", async () => {
|
|
const lane = await connectSharedSlack("channels:read chat:write");
|
|
|
|
await lane.refreshWithScope("channels:read chat:write");
|
|
|
|
// Judging the response against the widened grant instead of the original request would
|
|
// read this back as clean, because the over-grant would have become its own baseline.
|
|
expect((await lane.readGrant()).providerTenant?.oauth).toMatchObject({
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
});
|
|
|
|
it("clears the warning when a refresh narrows the scope to the request", async () => {
|
|
const lane = await connectSharedSlack("channels:read chat:write");
|
|
|
|
await lane.refreshWithScope("channels:read");
|
|
|
|
// A fresh assertion *is* evidence, so the warning is not sticky once the provider says
|
|
// the grant has narrowed.
|
|
expect((await lane.readGrant()).providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: [],
|
|
});
|
|
expect(await lane.readConnectionOAuth()).toMatchObject({
|
|
scopeSource: "provider",
|
|
unrequestedScopes: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
it("uses Airtable DCR when both DCR and CIMD are advertised", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const restoreMethod = optIntoAirtableDcrRegistration();
|
|
try {
|
|
const company = await createCompany(db);
|
|
let metadataLookups = 0;
|
|
let registrations = 0;
|
|
const service = createTestToolAccessService(db, {
|
|
oauthClientMetadataLookup: async () => {
|
|
metadataLookups++;
|
|
return [{ address: "93.184.216.34", family: 4 }];
|
|
},
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable DCR",
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.airtable.com",
|
|
authorization_servers: ["https://airtable.com/oauth2/v1"],
|
|
scopes_supported: ["data.records:read", "data.records:write"],
|
|
});
|
|
}
|
|
if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://airtable.com/oauth2/v1",
|
|
authorization_endpoint: "https://airtable.com/oauth2/v1/authorize",
|
|
token_endpoint: "https://airtable.com/oauth2/v1/token",
|
|
registration_endpoint: "https://airtable.com/oauth2/v1/register",
|
|
client_id_metadata_document_supported: true,
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
}
|
|
if (href === "https://airtable.com/oauth2/v1/register") {
|
|
registrations++;
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
application_type: "web",
|
|
});
|
|
return mcpHttpResponse({
|
|
client_id: "airtable-dcr-client",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const start = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
expect(start.registrationSource).toBe("dcr");
|
|
expect(new URL(start.authorizationUrl).searchParams.get("client_id")).toBe("airtable-dcr-client");
|
|
expect(registrations).toBe(1);
|
|
expect(metadataLookups).toBe(0);
|
|
} finally {
|
|
restoreMethod();
|
|
}
|
|
});
|
|
|
|
it("rebinds an Airtable CIMD client through DCR when the curated method opts in", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
oauthClientMetadataLookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable migration",
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
let registrations = 0;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.airtable.com",
|
|
authorization_servers: ["https://airtable.com/oauth2/v1"],
|
|
});
|
|
}
|
|
if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://airtable.com/oauth2/v1",
|
|
authorization_endpoint: "https://airtable.com/oauth2/v1/authorize",
|
|
token_endpoint: "https://airtable.com/oauth2/v1/token",
|
|
registration_endpoint: "https://airtable.com/oauth2/v1/register",
|
|
client_id_metadata_document_supported: true,
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
}
|
|
if (href === "https://airtable.com/oauth2/v1/register") {
|
|
registrations++;
|
|
return mcpHttpResponse({
|
|
client_id: "airtable-rebound-dcr-client",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const airtableMethod = APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods
|
|
.find((entry) => entry.key === "mcp-oauth")!;
|
|
const originalRegistration = airtableMethod.oauthClientRegistration;
|
|
try {
|
|
// Generated Airtable metadata now opts into DCR. Remove that curated
|
|
// flag here to model an existing client binding created before the opt-in.
|
|
delete airtableMethod.oauthClientRegistration;
|
|
const cimdStart = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
expect(cimdStart.registrationSource).toBe("cimd");
|
|
|
|
airtableMethod.oauthClientRegistration = "dcr";
|
|
const dcrStart = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
expect(dcrStart.registrationSource).toBe("dcr");
|
|
expect(new URL(dcrStart.authorizationUrl).searchParams.get("client_id")).toBe("airtable-rebound-dcr-client");
|
|
expect(registrations).toBe(1);
|
|
} finally {
|
|
if (originalRegistration === undefined) delete airtableMethod.oauthClientRegistration;
|
|
else airtableMethod.oauthClientRegistration = originalRegistration;
|
|
}
|
|
});
|
|
|
|
it("does not fall back to Airtable CIMD when DCR is required but unavailable", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const restoreMethod = optIntoAirtableDcrRegistration();
|
|
try {
|
|
const company = await createCompany(db);
|
|
let metadataLookups = 0;
|
|
const service = createTestToolAccessService(db, {
|
|
oauthClientMetadataLookup: async () => {
|
|
metadataLookups++;
|
|
return [{ address: "93.184.216.34", family: 4 }];
|
|
},
|
|
});
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable DCR unavailable",
|
|
});
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.airtable.com",
|
|
authorization_servers: ["https://airtable.com/oauth2/v1"],
|
|
});
|
|
}
|
|
if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://airtable.com/oauth2/v1",
|
|
authorization_endpoint: "https://airtable.com/oauth2/v1/authorize",
|
|
token_endpoint: "https://airtable.com/oauth2/v1/token",
|
|
client_id_metadata_document_supported: true,
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await expect(service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
})).rejects.toMatchObject({ details: { code: "oauth_dcr_not_supported" } });
|
|
expect(metadataLookups).toBe(0);
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
expect((connection.config.oauth as Record<string, unknown> | undefined)?.clientId).toBeUndefined();
|
|
} finally {
|
|
restoreMethod();
|
|
}
|
|
});
|
|
|
|
it("registers Linear against its MCP authorization server instead of the pinned console endpoints", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const userId = `linear-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
);
|
|
const fetched: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
fetched.push(href);
|
|
if (href === "https://mcp.linear.app/.well-known/oauth-protected-resource/mcp") {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.linear.app/mcp",
|
|
authorization_servers: ["https://mcp.linear.app"],
|
|
scopes_supported: ["read", "write"],
|
|
});
|
|
}
|
|
if (href === "https://mcp.linear.app/.well-known/oauth-authorization-server") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://mcp.linear.app",
|
|
authorization_endpoint: "https://mcp.linear.app/authorize",
|
|
token_endpoint: "https://mcp.linear.app/token",
|
|
registration_endpoint: "https://mcp.linear.app/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
}
|
|
if (href === "https://mcp.linear.app/register") {
|
|
return mcpHttpResponse({
|
|
client_id: "linear-registered-client",
|
|
redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "linear", connectionMethodKey: "mcp-oauth", name: "Linear", grantKind: "user" })
|
|
.expect(201);
|
|
|
|
const startUrl = new URL(connectRes.body.auth.startUrl);
|
|
expect(startUrl.origin + startUrl.pathname).toBe("https://mcp.linear.app/authorize");
|
|
expect(startUrl.searchParams.get("client_id")).toBe("linear-registered-client");
|
|
expect(fetched).toContain("https://mcp.linear.app/register");
|
|
expect(fetched.some((href) => href.startsWith("https://linear.app/"))).toBe(false);
|
|
});
|
|
|
|
it("registers Calendly with a provider-compatible DCR client name", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "calendly", connectionMethodKey: "mcp-oauth", name: "Calendly",
|
|
});
|
|
const redirectUri = "http://localhost:3105/api/tools/oauth/callback";
|
|
let registrationBody: Record<string, unknown> | null = null;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href.includes("oauth-protected-resource")) return mcpHttpResponse({
|
|
resource: "https://mcp.calendly.com/", authorization_servers: ["https://calendly.com/"],
|
|
scopes_supported: ["mcp:scheduling:read", "mcp:scheduling:write"],
|
|
});
|
|
if (href.includes("oauth-authorization-server")) return mcpHttpResponse({
|
|
issuer: "https://calendly.com/", authorization_endpoint: "https://calendly.com/oauth/authorize",
|
|
token_endpoint: "https://calendly.com/oauth/token", registration_endpoint: "https://calendly.com/oauth/register",
|
|
code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
if (href === "https://calendly.com/oauth/register") {
|
|
registrationBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
return mcpHttpResponse({
|
|
client_id: "calendly-public-client", redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const result = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
expect(registrationBody).toMatchObject({
|
|
client_name: "Paperclip localhost-3105",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
application_type: "web",
|
|
});
|
|
expect(registrationBody?.client_name).toMatch(/^[A-Za-z0-9 -]+$/);
|
|
expect(new URL(result.authorizationUrl).searchParams.get("client_id")).toBe("calendly-public-client");
|
|
});
|
|
|
|
it("returns a pre-scoped personal Notion callback directly to Permissions", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const userId = `notion-owner-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
authorization_servers: ["https://mcp.notion.com"],
|
|
scopes_supported: ["default"],
|
|
});
|
|
}
|
|
if (
|
|
href === "https://mcp.notion.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse(notionAuthorizationServer());
|
|
}
|
|
if (href === "https://mcp.notion.com/register") {
|
|
return mcpHttpResponse({
|
|
client_id: "notion-choice-client",
|
|
client_secret: "notion-choice-secret",
|
|
redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
if (href === "https://mcp.notion.com/token") {
|
|
expect((init?.body as URLSearchParams).get("code")).toBe(
|
|
"notion-choice-code",
|
|
);
|
|
return mcpHttpResponse({
|
|
access_token: "notion-choice-access",
|
|
refresh_token: "notion-choice-refresh",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.notion.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer notion-choice-access",
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "notion-search", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "notion", name: "Notion choice", grantKind: "user" })
|
|
.expect(201);
|
|
const state = new URL(connectRes.body.auth.startUrl).searchParams.get(
|
|
"state",
|
|
);
|
|
expect(state).toBeTruthy();
|
|
|
|
// The provider's redirect is a cross-site navigation: Paperclip commits a
|
|
// page at once (Railway's consent page otherwise replaces itself after ~2s)
|
|
// and leaves the state unconsumed for the same-origin repeat.
|
|
const interstitialRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Accept", "text/html")
|
|
.set("Sec-Fetch-Site", "cross-site")
|
|
.set("Sec-Fetch-Mode", "navigate")
|
|
.query({ state, code: "notion-choice-code" });
|
|
expect(interstitialRes.status).toBe(200);
|
|
expect(interstitialRes.headers["cache-control"]).toBe("no-store");
|
|
expect(interstitialRes.text).toContain(
|
|
`<meta http-equiv="refresh" content="0;url=/api/tools/oauth/callback?state=${state}&code=notion-choice-code">`,
|
|
);
|
|
const [pendingConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connectRes.body.connectionId));
|
|
expect(pendingConnection?.status).not.toBe("active");
|
|
|
|
const callbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Accept", "text/html")
|
|
.set("Sec-Fetch-Site", "same-origin")
|
|
.set("Sec-Fetch-Mode", "navigate")
|
|
.query({ state, code: "notion-choice-code" });
|
|
|
|
expect(callbackRes.status).toBe(303);
|
|
expect(callbackRes.headers.location).toBe(
|
|
`/${company.issuePrefix}/apps/${connectRes.body.connectionId}/permissions?success=1`,
|
|
);
|
|
const [activeConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connectRes.body.connectionId));
|
|
expect(activeConnection).toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "ok",
|
|
credentialPolicy: "per_user",
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
eq(toolCatalogEntries.connectionId, connectRes.body.connectionId),
|
|
),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ toolName: "notion-search", status: "active" }),
|
|
]);
|
|
});
|
|
|
|
it("returns a declined curated OAuth draft to its exact resumable setup route", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
|
const company = await createCompany(db);
|
|
const userId = `notion-resume-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", userId),
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
authorization_servers: ["https://mcp.notion.com"],
|
|
});
|
|
}
|
|
if (
|
|
href === "https://mcp.notion.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse(notionAuthorizationServer());
|
|
}
|
|
if (href === "https://mcp.notion.com/register") {
|
|
return mcpHttpResponse({
|
|
client_id: "notion-resume-client",
|
|
redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({
|
|
galleryKey: "notion",
|
|
name: "Notion interrupted",
|
|
grantKind: "user",
|
|
})
|
|
.expect(201);
|
|
const state = new URL(connectRes.body.auth.startUrl).searchParams.get(
|
|
"state",
|
|
);
|
|
expect(state).toBeTruthy();
|
|
|
|
const callbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Accept", "text/html")
|
|
.query({ state, error: "access_denied" });
|
|
|
|
expect(callbackRes.status).toBe(303);
|
|
const location = new URL(
|
|
callbackRes.headers.location,
|
|
"https://paperclip.example",
|
|
);
|
|
expect(location.pathname).toBe(`/${company.issuePrefix}/apps/connect`);
|
|
expect(location.searchParams.get("source")).toBe("notion");
|
|
expect(location.searchParams.get("resume")).toBe(
|
|
connectRes.body.connectionId,
|
|
);
|
|
expect(location.searchParams.get("oauth")).toBe("denied");
|
|
expect(location.searchParams.get("code")).toBe(
|
|
"oauth_authorization_denied",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"https://paperclip-public.example",
|
|
"http://127.0.0.1:3200",
|
|
"http://localhost:3200",
|
|
])(
|
|
"starts and completes OAuth with the same redirect URI at %s",
|
|
async (origin) => {
|
|
const callbackOrigin = origin.replace("http://127.0.0.1:", "http://localhost:");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
vi.stubEnv(
|
|
"PAPERCLIP_PUBLIC_URL",
|
|
origin.startsWith("https:") ? origin : "",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board-user", [
|
|
"tools:manage_connections",
|
|
]);
|
|
const app = createRouteApp(db);
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", new URL(origin).host)
|
|
.set("Origin", origin)
|
|
.send({ galleryKey: "slack", name: "Slack workspace" });
|
|
|
|
expect(connectRes.status).toBe(201);
|
|
expect(connectRes.body.connection).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
credentialSecretRefs: [],
|
|
config: expect.objectContaining({ sourceTemplateKey: "slack" }),
|
|
});
|
|
const startUrl = new URL(connectRes.body.auth.startUrl);
|
|
expect(`${startUrl.origin}${startUrl.pathname}`).toBe(
|
|
"https://slack.com/oauth/v2/authorize",
|
|
);
|
|
expect(startUrl.searchParams.get("client_id")).toBe("slack-client-id");
|
|
expect(startUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
|
expect(startUrl.searchParams.get("code_challenge")).toMatch(
|
|
/^[A-Za-z0-9_-]{43}$/,
|
|
);
|
|
expect(startUrl.searchParams.get("redirect_uri")).toBe(
|
|
`${callbackOrigin}/api/tools/oauth/callback`,
|
|
);
|
|
const state = startUrl.searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
await expect(db.select().from(toolOauthStates)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
state,
|
|
connectionId: connectRes.body.connectionId,
|
|
companyId: company.id,
|
|
createdByActorType: "user",
|
|
createdByActorId: "board-user",
|
|
createdBySessionId: null,
|
|
}),
|
|
]);
|
|
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("grant_type")).toBe("authorization_code");
|
|
expect(body.get("code")).toBe("oauth-code");
|
|
expect(body.get("client_secret")).toBe("slack-client-secret");
|
|
expect(body.get("code_verifier")).toBeTruthy();
|
|
expect(body.get("redirect_uri")).toBe(
|
|
`${callbackOrigin}/api/tools/oauth/callback`,
|
|
);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
ok: true,
|
|
access_token: "access-token",
|
|
refresh_token: "refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
scope: "channels:read chat:write search:read",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({ Authorization: "Bearer access-token" }),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "search_messages",
|
|
description: "Search messages.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "send_message",
|
|
description: "Send a message.",
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const callbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Host", new URL(callbackOrigin).host)
|
|
.query({ state, code: "oauth-code" });
|
|
|
|
expect(callbackRes.status).toBe(200);
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(callbackRes.body.connection).toMatchObject({
|
|
id: connectRes.body.connectionId,
|
|
status: "active",
|
|
enabled: true,
|
|
credentialSecretRefs: [
|
|
expect.objectContaining({
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
}),
|
|
expect.objectContaining({
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
}),
|
|
],
|
|
});
|
|
expect(callbackRes.body.actions.readOnly).toEqual([
|
|
expect.objectContaining({
|
|
toolName: "search_messages",
|
|
riskLevel: "read",
|
|
}),
|
|
]);
|
|
expect(callbackRes.body.actions.canMakeChanges).toEqual([
|
|
expect.objectContaining({
|
|
toolName: "send_message",
|
|
riskLevel: "write",
|
|
}),
|
|
]);
|
|
|
|
const redirectConnectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", new URL(origin).host)
|
|
.set("Origin", origin)
|
|
.send({ galleryKey: "slack", name: "Slack redirect" })
|
|
.expect(201);
|
|
const redirectState = new URL(
|
|
redirectConnectRes.body.auth.startUrl,
|
|
).searchParams.get("state");
|
|
expect(redirectState).toBeTruthy();
|
|
const redirectCallbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Host", new URL(callbackOrigin).host)
|
|
.set("Accept", "text/html")
|
|
.query({ state: redirectState, code: "oauth-code" });
|
|
|
|
expect(redirectCallbackRes.status).toBe(303);
|
|
expect(redirectCallbackRes.headers.location).toBe(
|
|
`/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/permissions?success=1`,
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
|
|
await expect(
|
|
db.select().from(companySecretBindings),
|
|
).resolves.toHaveLength(4);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connectRes.body.connectionId));
|
|
expect(JSON.stringify(connection.config)).not.toContain("access-token");
|
|
expect(JSON.stringify(connection.config)).not.toContain("refresh-token");
|
|
},
|
|
);
|
|
|
|
it("normalizes a direct numeric loopback origin for OAuth when no public URL is configured", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", "127.0.0.1:3200")
|
|
.set("Origin", "http://127.0.0.1:3200")
|
|
.send({ galleryKey: "slack", name: "Loopback Slack workspace" });
|
|
|
|
expect(connectRes.status).toBe(201);
|
|
const startUrl = new URL(connectRes.body.auth.startUrl);
|
|
expect(startUrl.searchParams.get("redirect_uri")).toBe(
|
|
"http://localhost:3200/api/tools/oauth/callback",
|
|
);
|
|
});
|
|
|
|
it("does not derive an OAuth callback origin from a non-loopback request host", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", "paperclip.example.test")
|
|
.set("X-Forwarded-Host", "127.0.0.1:3200")
|
|
.send({
|
|
galleryKey: "slack",
|
|
name: "Unconfigured public Slack workspace",
|
|
});
|
|
|
|
expect(connectRes.status).toBe(422);
|
|
expect(connectRes.body).toMatchObject({
|
|
code: "oauth_redirect_origin_unsupported",
|
|
error:
|
|
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
|
|
});
|
|
});
|
|
|
|
it("uses an authenticated same-origin HTTPS browser request without public URL config", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner"),
|
|
undefined,
|
|
{
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
},
|
|
);
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", "paperclip.tail123.ts.net")
|
|
.set("Origin", "https://paperclip.tail123.ts.net")
|
|
.send({ galleryKey: "slack", name: "Tailscale Slack workspace" });
|
|
|
|
expect(connectRes.status).toBe(201);
|
|
expect(
|
|
new URL(connectRes.body.auth.startUrl).searchParams.get("redirect_uri"),
|
|
).toBe("https://paperclip.tail123.ts.net/api/tools/oauth/callback");
|
|
});
|
|
|
|
it("rejects a browser HTTPS origin that does not match the routed request host", async () => {
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner"),
|
|
undefined,
|
|
{
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
},
|
|
);
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.set("Host", "paperclip.tail123.ts.net")
|
|
.set("Origin", "https://evil.example")
|
|
.send({ galleryKey: "slack", name: "Mismatched Slack workspace" });
|
|
|
|
expect(connectRes.status).toBe(422);
|
|
expect(connectRes.body).toMatchObject({
|
|
code: "oauth_redirect_origin_unsupported",
|
|
});
|
|
});
|
|
|
|
it("requires non-viewer board access to start OAuth for active app connections", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "slack", name: "Slack reauth" },
|
|
{ actorType: "user", actorId: "operator-user" },
|
|
);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "active", updatedAt: new Date() })
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer", "viewer-user"),
|
|
);
|
|
await request(viewerApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({})
|
|
.expect(403);
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ galleryKey: "slack", name: "Viewer Slack" })
|
|
.expect(403);
|
|
|
|
const operatorActor = boardSessionActor(
|
|
company.id,
|
|
"operator",
|
|
"operator-user",
|
|
);
|
|
const operatorApp = createRouteApp(db, operatorActor);
|
|
const startRes = await request(operatorApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({})
|
|
.expect(200);
|
|
|
|
const state = new URL(startRes.body.authorizationUrl).searchParams.get(
|
|
"state",
|
|
);
|
|
expect(state).toBeTruthy();
|
|
await expect(db.select().from(toolOauthStates)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
state,
|
|
connectionId: connect.connectionId,
|
|
companyId: company.id,
|
|
createdByActorType: "user",
|
|
createdByActorId: "operator-user",
|
|
createdBySessionId: operatorActor.sessionId,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("lets the retained personal identity owner reconnect without manager configuration access", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
|
const company = await createCompany(db);
|
|
const userId = `personal-oauth-member-${randomUUID()}`;
|
|
const service = createTestToolAccessService(db);
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "slack", name: "Legacy personal Slack", grantKind: "user" },
|
|
{ actorType: "user", actorId: userId },
|
|
);
|
|
// Older personal rows may retain the user grant without a creator on the
|
|
// connection. Reconnect belongs to the grant subject, not only a manager.
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
createdByUserId: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connect.connectionId,
|
|
kind: "user",
|
|
subjectUserId: userId,
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
createdByUserId: userId,
|
|
});
|
|
|
|
const memberApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
);
|
|
const start = await request(memberApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({ asCurrentUser: true })
|
|
.expect(200);
|
|
const state = new URL(start.body.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
await expect(service.peekOAuthState(state)).resolves.toMatchObject({
|
|
subjectUserId: userId,
|
|
});
|
|
|
|
const otherMemberApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", `other-${randomUUID()}`),
|
|
);
|
|
await request(otherMemberApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({ asCurrentUser: true })
|
|
.expect(403);
|
|
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer", userId),
|
|
);
|
|
await request(viewerApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({ asCurrentUser: true })
|
|
.expect(403);
|
|
});
|
|
|
|
it("requires non-viewer board access to finish app activation and bind profiles", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{
|
|
name: "kv_get",
|
|
description: "Read a value.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { key: { type: "string" } },
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://secure.example/mcp",
|
|
name: "Viewer finish blocked",
|
|
credentialValues: { "headers.Authorization": "Bearer imported-token" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const bindingsBefore = await db
|
|
.select({ id: toolProfileBindings.id })
|
|
.from(toolProfileBindings);
|
|
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer", "viewer-user"),
|
|
);
|
|
await request(viewerApp)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/apps/${connect.connectionId}/finish`,
|
|
)
|
|
.send({
|
|
enabledCatalogEntryIds: connect.catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
})
|
|
.expect(403);
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
expect(connection.status).toBe("draft");
|
|
expect(connection.enabled).toBe(false);
|
|
await expect(
|
|
db.select({ id: toolProfileBindings.id }).from(toolProfileBindings),
|
|
).resolves.toEqual(bindingsBefore);
|
|
});
|
|
|
|
it("binds OAuth callback completion to the initiating board session", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "oauth-operator", [
|
|
"tools:manage_connections",
|
|
]);
|
|
const service = createTestToolAccessService(db);
|
|
const initiatingActor = boardSessionActor(
|
|
company.id,
|
|
"operator",
|
|
"oauth-operator",
|
|
);
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{ galleryKey: "slack", name: "Slack bound" },
|
|
{ actorType: "user", actorId: initiatingActor.userId },
|
|
);
|
|
const initiatingApp = createRouteApp(db, initiatingActor);
|
|
const startRes = await request(initiatingApp)
|
|
.post(`/api/tools/oauth/${connect.connectionId}/start`)
|
|
.send({})
|
|
.expect(200);
|
|
const state = new URL(startRes.body.authorizationUrl).searchParams.get(
|
|
"state",
|
|
)!;
|
|
|
|
const anonymousApp = createRouteApp(db, { type: "none", source: "none" });
|
|
await request(anonymousApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(403);
|
|
|
|
const otherApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", "other-operator"),
|
|
);
|
|
await request(otherApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(403);
|
|
|
|
const otherSessionSameUserApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(
|
|
company.id,
|
|
"operator",
|
|
"oauth-operator",
|
|
"other-session",
|
|
),
|
|
);
|
|
await request(otherSessionSameUserApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(403);
|
|
|
|
const downgradedActor = {
|
|
...initiatingActor,
|
|
companyIds: [company.id],
|
|
memberships: [
|
|
{
|
|
companyId: company.id,
|
|
membershipRole: "viewer" as const,
|
|
status: "active",
|
|
},
|
|
],
|
|
};
|
|
const downgradedApp = createRouteApp(db, downgradedActor);
|
|
await request(downgradedApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(403);
|
|
|
|
await db
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, company.id),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(principalPermissionGrants.principalId, "oauth-operator"),
|
|
eq(
|
|
principalPermissionGrants.permissionKey,
|
|
"tools:manage_connections",
|
|
),
|
|
),
|
|
);
|
|
await request(initiatingApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(403);
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: "oauth-operator",
|
|
permissionKey: "tools:manage_connections",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
});
|
|
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1);
|
|
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("grant_type")).toBe("authorization_code");
|
|
expect(body.get("code")).toBe("oauth-code");
|
|
return {
|
|
ok: true,
|
|
json: async () => (oauthToken("bound-access-token", "bound-refresh-token")),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer bound-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "search_messages", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await request(initiatingApp)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "oauth-code" })
|
|
.expect(200);
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("discovers Notion MCP OAuth metadata, registers one public client, and reuses it", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "notion",
|
|
name: "Notion DCR",
|
|
});
|
|
const redirectUri =
|
|
"https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
|
|
const registrationBodies: Array<Record<string, unknown>> = [];
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
authorization_servers: ["https://mcp.notion.com"],
|
|
scopes_supported: ["default"],
|
|
});
|
|
}
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse(notionAuthorizationServer());
|
|
}
|
|
if (href === "https://mcp.notion.com/register") {
|
|
expect(init?.method).toBe("POST");
|
|
registrationBodies.push(
|
|
JSON.parse(String(init?.body)) as Record<string, unknown>,
|
|
);
|
|
return mcpHttpResponse({
|
|
client_id: "notion-dcr-client",
|
|
client_secret: "notion-dcr-secret",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const [first, concurrent] = await Promise.all([
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
]);
|
|
|
|
expect(new URL(first.authorizationUrl).origin).toBe(
|
|
"https://mcp.notion.com",
|
|
);
|
|
expect(
|
|
new URL(first.authorizationUrl).searchParams.get("scope"),
|
|
).toBe("default");
|
|
expect(
|
|
new URL(concurrent.authorizationUrl).searchParams.get("client_id"),
|
|
).toBe("notion-dcr-client");
|
|
expect(registrationBodies).toEqual([
|
|
{
|
|
client_name: "Paperclip paperclip-dev-tail29c1aa-ts-net",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
// PAP-17087: Paperclip's callback is a server-side HTTPS endpoint, so
|
|
// registration must declare a `web` client rather than let the
|
|
// authorization server apply native-client redirect rules.
|
|
application_type: "web",
|
|
},
|
|
]);
|
|
|
|
fetchMock.mockClear();
|
|
const reused = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
expect(new URL(reused.authorizationUrl).searchParams.get("client_id")).toBe(
|
|
"notion-dcr-client",
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
scopes: ["unreviewed:admin"],
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
details: {
|
|
code: "oauth_scope_widening_rejected",
|
|
scopes: ["unreviewed:admin"],
|
|
},
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
const [damagedDcrConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...damagedDcrConnection.config,
|
|
oauth: {
|
|
...(damagedDcrConnection.config.oauth as Record<string, unknown>),
|
|
clientRegistrationSource: "manual",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
fetchMock.mockClear();
|
|
|
|
const repaired = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
expect(repaired.registrationSource).toBe("dcr");
|
|
expect(
|
|
fetchMock.mock.calls.filter(
|
|
([url]) => String(url) === "https://mcp.notion.com/register",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection).toMatchObject({ ownership: "dcr" });
|
|
expect(connection.config).toMatchObject({
|
|
oauth: {
|
|
provider: "notion",
|
|
clientId: "notion-dcr-client",
|
|
clientRegistrationSource: "dcr",
|
|
clientTokenEndpointAuthMethod: "none",
|
|
clientRedirectUri: redirectUri,
|
|
registrationUrl: "https://mcp.notion.com/register",
|
|
scopes: ["default"],
|
|
},
|
|
});
|
|
expect(connection.credentialSecretRefs).toEqual([
|
|
expect.objectContaining({
|
|
configPath: "oauth.client_secret",
|
|
required: false,
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(connection.config)).not.toContain(
|
|
"notion-dcr-secret",
|
|
);
|
|
});
|
|
|
|
it("supports confidential DCR clients without exposing their registration secret", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SUPABASE_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SUPABASE_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Supabase confidential DCR",
|
|
configValues: {
|
|
projectRef: "abcdefghijklmnopqrst",
|
|
readOnly: true,
|
|
features: "database",
|
|
},
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.supabase.com/mcp",
|
|
authorization_servers: ["https://api.supabase.com"],
|
|
});
|
|
}
|
|
if (
|
|
href ===
|
|
"https://api.supabase.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse({
|
|
issuer: "https://api.supabase.com",
|
|
authorization_endpoint:
|
|
"https://api.supabase.com/v1/oauth/authorize",
|
|
token_endpoint: "https://api.supabase.com/v1/oauth/token",
|
|
registration_endpoint:
|
|
"https://api.supabase.com/platform/oauth/apps/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"client_secret_basic",
|
|
"client_secret_post",
|
|
],
|
|
});
|
|
}
|
|
if (href === "https://api.supabase.com/platform/oauth/apps/register") {
|
|
const requestBody = JSON.parse(String(init?.body)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(requestBody.token_endpoint_auth_method).toBe(
|
|
"client_secret_basic",
|
|
);
|
|
return mcpHttpResponse({
|
|
client_id: "supabase-dcr-client",
|
|
client_secret: "supabase-dcr-secret",
|
|
redirect_uris: [redirectUri],
|
|
// Supabase's live DCR response intentionally omits the request
|
|
// metadata it accepted and returns only client material + redirects.
|
|
client_secret_expires_at: 0,
|
|
});
|
|
}
|
|
if (href === "https://api.supabase.com/v1/oauth/token") {
|
|
const headers = init?.headers as Record<string, string>;
|
|
expect(headers.Authorization).toBe(
|
|
`Basic ${Buffer.from("supabase-dcr-client:supabase-dcr-secret").toString("base64")}`,
|
|
);
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("client_id")).toBeNull();
|
|
expect(body.get("client_secret")).toBeNull();
|
|
expect(body.get("code")).toBe("supabase-code");
|
|
return mcpHttpResponse({
|
|
access_token: "supabase-access-token",
|
|
refresh_token: "supabase-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (
|
|
href ===
|
|
"https://mcp.supabase.com/mcp?project_ref=abcdefghijklmnopqrst&read_only=true&features=database"
|
|
) {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer supabase-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "list_tables", annotations: { readOnlyHint: true } },
|
|
{ name: "execute_sql", annotations: { readOnlyHint: false } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
const completed = await service.completeOAuthCallback({
|
|
state: state!,
|
|
code: "supabase-code",
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
expect(completed.actions.readOnly).toEqual([
|
|
expect.objectContaining({ toolName: "list_tables", riskLevel: "read" }),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(eq(toolPolicies.companyId, company.id)),
|
|
).resolves.toEqual([]);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
oauth: {
|
|
clientId: "supabase-dcr-client",
|
|
clientRegistrationSource: "dcr",
|
|
clientTokenEndpointAuthMethod: "client_secret_basic",
|
|
},
|
|
});
|
|
expect(connection.credentialSecretRefs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ configPath: "oauth.client_secret" }),
|
|
expect.objectContaining({ configPath: "oauth.access_token" }),
|
|
expect.objectContaining({ configPath: "oauth.refresh_token" }),
|
|
]),
|
|
);
|
|
expect(JSON.stringify(connection.config)).not.toContain(
|
|
"supabase-dcr-secret",
|
|
);
|
|
expect(JSON.stringify(completed)).not.toContain("supabase-dcr-secret");
|
|
});
|
|
|
|
it.each(["default", "none", "agent", "company"] as const)(
|
|
"negotiates JSON OAuth tokens and preserves MCP agent reach (%s)", async (reach) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "miro",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Miro DCR",
|
|
});
|
|
const agent = await createAgent(db, company.id);
|
|
const installs = reach === "company" || reach === "default"
|
|
? [{ targetType: "company" as const, targetId: company.id }]
|
|
: reach === "agent"
|
|
? [{ targetType: "agent" as const, targetId: agent.id }]
|
|
: [];
|
|
if (reach !== "default") {
|
|
await service.putConnectionInstalls(connected.connectionId, { installs });
|
|
}
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href === "https://mcp.miro.com/.well-known/oauth-protected-resource"
|
|
) {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.miro.com/",
|
|
authorization_servers: ["https://mcp.miro.com/"],
|
|
});
|
|
}
|
|
if (
|
|
href === "https://mcp.miro.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse({
|
|
issuer: "https://mcp.miro.com/",
|
|
authorization_endpoint: "https://mcp.miro.com/authorize",
|
|
token_endpoint: "https://mcp.miro.com/token",
|
|
registration_endpoint: "https://mcp.miro.com/register",
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"client_secret_post",
|
|
"client_secret_basic",
|
|
],
|
|
});
|
|
}
|
|
if (href === "https://mcp.miro.com/register") {
|
|
const requestBody = JSON.parse(String(init?.body)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(requestBody.token_endpoint_auth_method).toBe(
|
|
"client_secret_post",
|
|
);
|
|
return mcpHttpResponse({
|
|
client_id: "miro-dcr-client",
|
|
client_secret: "miro-dcr-secret",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
});
|
|
}
|
|
if (href === "https://mcp.miro.com/token") {
|
|
const headers = new Headers(init?.headers);
|
|
const body = init?.body as URLSearchParams;
|
|
// Match providers that negotiate JSON only when explicitly requested.
|
|
if (headers.get("accept") !== "application/json") {
|
|
return new Response("access_token=miro-access-token", {
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
});
|
|
}
|
|
expect(headers.get("authorization")).toBeNull();
|
|
expect(body.get("client_id")).toBe("miro-dcr-client");
|
|
expect(body.get("client_secret")).toBe("miro-dcr-secret");
|
|
expect(body.get("code")).toBe("miro-code");
|
|
return mcpHttpResponse({
|
|
access_token: "miro-access-token",
|
|
refresh_token: "miro-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.miro.com/") {
|
|
expect(new Headers(init?.headers).get("authorization")).toBe(
|
|
"Bearer miro-access-token",
|
|
);
|
|
return mcpHttpResponse(catalogResponse([{ name: "whoami", annotations: { readOnlyHint: true } }]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
const state = new URL(started.authorizationUrl).searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
const completed = await service.completeOAuthCallback({
|
|
state: state!,
|
|
code: "miro-code",
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
expect(completed.actions.readOnly).toEqual([
|
|
expect.objectContaining({ toolName: "whoami", riskLevel: "read" }),
|
|
]);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
oauth: { clientTokenEndpointAuthMethod: "client_secret_post" },
|
|
});
|
|
expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret");
|
|
expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret");
|
|
const installed = await db.select().from(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.connectionId, connected.connectionId));
|
|
expect(installed.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs);
|
|
const [profile] = await db.select().from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
|
|
const bindings = await db.select().from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, profile.id));
|
|
expect(bindings.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs);
|
|
},
|
|
);
|
|
|
|
it("accepts provider-added DCR grants without adopting them", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "hugging-face",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Hugging Face DCR",
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://huggingface.co/.well-known/oauth-protected-resource/mcp?login&gradio=none"
|
|
) {
|
|
return mcpHttpResponse({
|
|
resource: "https://huggingface.co/mcp?login&gradio=none",
|
|
authorization_servers: ["https://huggingface.co"],
|
|
});
|
|
}
|
|
if (
|
|
href === "https://huggingface.co/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse({
|
|
issuer: "https://huggingface.co",
|
|
authorization_endpoint: "https://huggingface.co/oauth/authorize",
|
|
token_endpoint: "https://huggingface.co/oauth/token",
|
|
registration_endpoint: "https://huggingface.co/oauth/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"client_secret_basic",
|
|
"client_secret_post",
|
|
],
|
|
});
|
|
}
|
|
if (href === "https://huggingface.co/oauth/register") {
|
|
const requestBody = JSON.parse(String(init?.body)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(requestBody.grant_types).toEqual([
|
|
"authorization_code",
|
|
"refresh_token",
|
|
]);
|
|
expect(requestBody.response_types).toEqual(["code"]);
|
|
return mcpHttpResponse({
|
|
client_id: "hugging-face-dcr-client",
|
|
client_secret: "hugging-face-dcr-secret",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: [
|
|
"urn:ietf:params:oauth:grant-type:device_code",
|
|
"authorization_code",
|
|
"refresh_token",
|
|
],
|
|
response_types: ["code", "token"],
|
|
token_endpoint_auth_method: "client_secret_basic",
|
|
client_secret_expires_at: 0,
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
|
|
expect(new URL(started.authorizationUrl).origin).toBe(
|
|
"https://huggingface.co",
|
|
);
|
|
expect(
|
|
new URL(started.authorizationUrl).searchParams.get("response_type"),
|
|
).toBe("code");
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
oauth: {
|
|
clientId: "hugging-face-dcr-client",
|
|
clientRegistrationSource: "dcr",
|
|
clientTokenEndpointAuthMethod: "client_secret_basic",
|
|
},
|
|
});
|
|
expect(JSON.stringify(connection.config)).not.toContain("device_code");
|
|
expect(JSON.stringify(connection.config)).not.toContain(
|
|
"hugging-face-dcr-secret",
|
|
);
|
|
});
|
|
|
|
it("does not request refresh-token registration from a provider that explicitly omits it", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CODA_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CODA_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "coda",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Coda DCR",
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (
|
|
href === "https://coda.io/.well-known/oauth-protected-resource/apis/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
resource: "https://coda.io/apis/mcp",
|
|
authorization_servers: ["https://coda.io"],
|
|
});
|
|
}
|
|
if (href === "https://coda.io/.well-known/oauth-authorization-server") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://coda.io",
|
|
authorization_endpoint: "https://coda.io/v4/api/oauth2/authorize",
|
|
token_endpoint: "https://coda.io/v4/api/oauth2/token",
|
|
registration_endpoint: "https://coda.io/v4/api/oauth2/register",
|
|
grant_types_supported: ["authorization_code"],
|
|
response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
});
|
|
}
|
|
if (href === "https://coda.io/v4/api/oauth2/register") {
|
|
const requestBody = JSON.parse(String(init?.body)) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(requestBody.grant_types).toEqual(["authorization_code"]);
|
|
return mcpHttpResponse({
|
|
client_id: "coda-dcr-client",
|
|
client_secret: "coda-dcr-secret",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
|
|
expect(new URL(started.authorizationUrl).origin).toBe("https://coda.io");
|
|
});
|
|
|
|
it("normalizes a public DCR client's zero secret expiry when no secret was issued", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIXPANEL_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIXPANEL_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "mixpanel",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name: "Mixpanel public DCR",
|
|
});
|
|
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.mixpanel.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
resource: "https://mcp.mixpanel.com/mcp",
|
|
authorization_servers: ["https://mcp.mixpanel.com/mcp"],
|
|
});
|
|
}
|
|
if (
|
|
href ===
|
|
"https://mcp.mixpanel.com/.well-known/oauth-authorization-server/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
issuer: "https://mcp.mixpanel.com/mcp",
|
|
authorization_endpoint: "https://mixpanel.com/oauth/authorize",
|
|
token_endpoint: "https://mixpanel.com/oauth/token/",
|
|
registration_endpoint: "https://mixpanel.com/oauth/mcp/register/",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: [
|
|
"none",
|
|
"client_secret_basic",
|
|
],
|
|
});
|
|
}
|
|
if (href === "https://mixpanel.com/oauth/mcp/register/") {
|
|
return mcpHttpResponse({
|
|
client_id: "mixpanel-public-client",
|
|
client_id_issued_at: 1_787_773_729,
|
|
client_secret_expires_at: 0,
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const started = await service.startOAuth(
|
|
company.id,
|
|
connected.connectionId,
|
|
{
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
},
|
|
);
|
|
|
|
expect(new URL(started.authorizationUrl).origin).toBe(
|
|
"https://mixpanel.com",
|
|
);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
oauth: {
|
|
clientId: "mixpanel-public-client",
|
|
clientSecretExpiresAt: null,
|
|
clientTokenEndpointAuthMethod: "none",
|
|
},
|
|
});
|
|
expect(connection.credentialSecretRefs).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ configPath: "oauth.client_secret" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it.each(["new", "saved"])("starts Sentry MCP sign-in for a %s connection without a manual OAuth app", async (state) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "sentry", connectionMethodKey: "mcp-oauth", name: "Sentry",
|
|
});
|
|
const redirectUri = "http://localhost:3200/api/tools/oauth/callback";
|
|
const issuer = "https://mcp.sentry.dev";
|
|
const resource = `${issuer}/mcp`;
|
|
const scopes = ["org:read", "project:write", "team:write", "event:write", "alerts:write"];
|
|
if (state === "saved") {
|
|
const [connection] = await db.select().from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
// An existing MCP registration must survive catalog changes. This is the
|
|
// state before a reconnect, with endpoints cached by successful discovery.
|
|
await db.update(toolConnections).set({ config: {
|
|
...connection.config,
|
|
oauth: {
|
|
...connection.config.oauth as Record<string, unknown>,
|
|
provider: "sentry", issuer, resource,
|
|
authorizationUrl: `${issuer}/oauth/authorize`, tokenUrl: `${issuer}/oauth/token`,
|
|
metadataUrl: `${issuer}/.well-known/oauth-authorization-server`,
|
|
clientId: "saved-sentry-client", clientRegistrationSource: "dcr",
|
|
clientIssuer: issuer, clientResource: resource,
|
|
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
|
clientTokenEndpointAuthMethod: "none",
|
|
},
|
|
} }).where(eq(toolConnections.id, connected.connectionId));
|
|
}
|
|
const calls: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
calls.push(href);
|
|
// Both hosts publish valid OAuth metadata. The web/API issuer cannot
|
|
// register an MCP client; discovery must select the resource's issuer.
|
|
if (href === "https://sentry.io/.well-known/oauth-authorization-server") {
|
|
return mcpHttpResponse({
|
|
issuer: "https://sentry.io", authorization_endpoint: "https://sentry.io/oauth/authorize/",
|
|
token_endpoint: "https://sentry.io/oauth/token/", code_challenge_methods_supported: ["S256"],
|
|
});
|
|
}
|
|
if (href === `${issuer}/.well-known/oauth-protected-resource/mcp`) {
|
|
return mcpHttpResponse({ resource, authorization_servers: [issuer], scopes_supported: scopes });
|
|
}
|
|
if (href === `${issuer}/.well-known/oauth-authorization-server`) {
|
|
return mcpHttpResponse({
|
|
issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`,
|
|
registration_endpoint: `${issuer}/oauth/register`, scopes_supported: scopes,
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
client_id_metadata_document_supported: true,
|
|
});
|
|
}
|
|
if (href === `${issuer}/oauth/register`) {
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
|
|
});
|
|
return mcpHttpResponse({
|
|
client_id: "new-sentry-client", redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri, actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const authorization = new URL(started.authorizationUrl);
|
|
expect(authorization.origin + authorization.pathname).toBe(`${issuer}/oauth/authorize`);
|
|
expect(authorization.searchParams.get("resource")).toBe(resource);
|
|
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes);
|
|
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
|
expect(authorization.searchParams.get("client_id")).toBe(`${state}-sentry-client`);
|
|
expect(calls).not.toContain("https://sentry.io/.well-known/oauth-authorization-server");
|
|
expect(calls.filter((url) => url === `${issuer}/oauth/register`)).toHaveLength(state === "new" ? 1 : 0);
|
|
const [saved] = await db.select().from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(saved.config.oauth).toMatchObject({
|
|
clientId: `${state}-sentry-client`, clientIssuer: issuer, clientResource: resource,
|
|
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
|
});
|
|
});
|
|
|
|
it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana",
|
|
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
|
|
});
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
await db.update(toolConnections).set({ config: {
|
|
...connection.config,
|
|
oauth: { ...connection.config.oauth as Record<string, unknown>,
|
|
issuer: "https://mcp.asana.com", authorizationUrl: "https://mcp.asana.com/authorize",
|
|
tokenUrl: "https://mcp.asana.com/token", resource: "https://mcp.asana.com",
|
|
clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com",
|
|
clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback" },
|
|
} }).where(eq(toolConnections.id, connected.connectionId));
|
|
const calls: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
calls.push(String(url));
|
|
if (String(url) === "https://mcp.asana.com/.well-known/oauth-protected-resource/v2") {
|
|
return mcpHttpResponse({ resource: "https://mcp.asana.com/v2/mcp",
|
|
authorization_servers: ["https://app.asana.com"], scopes_supported: ["default"] });
|
|
}
|
|
if (String(url) === "https://app.asana.com/.well-known/oauth-authorization-server") {
|
|
return mcpHttpResponse({ issuer: "https://app.asana.com",
|
|
authorization_endpoint: "https://app.asana.com/-/oauth_authorize",
|
|
token_endpoint: "https://app.asana.com/-/oauth_token",
|
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
code_challenge_methods_supported: ["S256"] });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
const started = await service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const authorization = new URL(started.authorizationUrl);
|
|
expect(authorization.origin + authorization.pathname).toBe("https://app.asana.com/-/oauth_authorize");
|
|
expect(authorization.searchParams.get("resource")).toBe("https://mcp.asana.com/v2/mcp");
|
|
expect(authorization.searchParams.get("scope")).toBe("default");
|
|
expect(authorization.searchParams.get("client_id")).toBe("asana-client");
|
|
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
|
expect(calls).toEqual([
|
|
"https://mcp.asana.com/.well-known/oauth-protected-resource/v2",
|
|
"https://app.asana.com/.well-known/oauth-authorization-server",
|
|
]);
|
|
const migrated = await service.getConnection(connected.connectionId, company.id);
|
|
expect(migrated.config?.oauth).toMatchObject({
|
|
clientIssuer: "https://app.asana.com", clientResource: "https://mcp.asana.com/v2/mcp",
|
|
});
|
|
expect(migrated.credentialSecretRefs).toEqual(connection.credentialSecretRefs);
|
|
for (const invalidBinding of [
|
|
{ clientIssuer: "https://other.example" },
|
|
{ clientResource: "https://other.example/mcp" },
|
|
{ clientCompanyId: randomUUID() },
|
|
{ clientRedirectUri: "https://other.example/api/tools/oauth/callback" },
|
|
]) {
|
|
await db.update(toolConnections).set({ config: { ...connection.config, oauth: {
|
|
...connection.config.oauth as Record<string, unknown>,
|
|
clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com",
|
|
clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback", ...invalidBinding,
|
|
} } }).where(eq(toolConnections.id, connection.id));
|
|
await expect(service.startOAuth(company.id, connection.id, {
|
|
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
})).rejects.toMatchObject({ status: 422, details: { code: "oauth_manual_client_rebinding_required" } });
|
|
}
|
|
const missingSecret = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth",
|
|
oauthClient: { clientId: "another-client" },
|
|
});
|
|
await expect(service.startOAuth(company.id, missingSecret.connectionId, {
|
|
redirectUri: "http://localhost:3200/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
})).rejects.toMatchObject({ status: 422, message: expect.stringContaining("requires an OAuth client secret") });
|
|
});
|
|
|
|
it("stores a curated customer-owned OAuth client without exposing its secret", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana",
|
|
connectionMethodKey: "mcp-own-oauth",
|
|
name: "Asana own app",
|
|
oauthClient: {
|
|
clientId: "asana-customer-client",
|
|
clientSecret: "asana-customer-secret",
|
|
},
|
|
});
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
sourceTemplateKey: "asana",
|
|
oauth: {
|
|
clientId: "asana-customer-client",
|
|
clientRegistrationSource: "manual",
|
|
clientCompanyId: company.id,
|
|
},
|
|
});
|
|
expect(connection.credentialSecretRefs).toEqual([
|
|
expect.objectContaining({
|
|
configPath: "oauth.client_secret",
|
|
required: false,
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(connection.config)).not.toContain(
|
|
"asana-customer-secret",
|
|
);
|
|
expect(JSON.stringify(connected)).not.toContain("asana-customer-secret");
|
|
|
|
const resumed = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana",
|
|
connectionMethodKey: "mcp-own-oauth",
|
|
name: "Asana own app",
|
|
resumeConnectionId: connected.connectionId,
|
|
oauthClient: {
|
|
clientId: "asana-customer-client",
|
|
},
|
|
});
|
|
const [resumedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, resumed.connectionId));
|
|
expect(resumedConnection.credentialSecretRefs).toEqual(
|
|
connection.credentialSecretRefs,
|
|
);
|
|
});
|
|
|
|
it.each([false, true])("finishes a second Asana account with a colliding profile name (installed: %s)", async (installed) => {
|
|
const company = await createCompany(db);
|
|
const userId = "asana-owner";
|
|
await grantBoardUser(db, company.id, userId, [], "owner");
|
|
const agent = await createAgent(db, company.id);
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const credentials = {
|
|
v: 1 as const, accessToken: "asana-access", refreshToken: "asana-refresh", tokenType: "Bearer",
|
|
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
refreshTokenExpiresAt: null, scopes: ["default"], subject: userId, companyId: company.id,
|
|
instanceId: "test-instance", environment: "staging" as const,
|
|
provider: "asana" as const, profile: "asana.mcp" as const,
|
|
};
|
|
const connector: PaperclipCloudConnector = {
|
|
getCapabilities: vi.fn(async () => ["asana.mcp"]),
|
|
startAuthorization: vi.fn(async ({ returnState }) => ({
|
|
authorizationUrl: `https://app.asana.com/-/oauth_authorize?state=${encodeURIComponent(returnState)}`,
|
|
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
})),
|
|
claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials),
|
|
revoke: vi.fn(async () => undefined),
|
|
};
|
|
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
|
const custom = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", grantKind: "user",
|
|
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
|
|
}, actor);
|
|
const first = await service.finishGalleryAppConnection(company.id, custom.connectionId, {
|
|
enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [] },
|
|
}, actor);
|
|
await service.updateConnection(custom.connectionId, { name: "Asana custom app" });
|
|
const managed = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "managed", name: "Asana", grantKind: "user",
|
|
applicationId: custom.application.id,
|
|
}, actor);
|
|
expect(managed.connection.name).toBe("Asana");
|
|
if (installed) await service.putConnectionInstalls(managed.connectionId, {
|
|
installs: [{ targetType: "agent", targetId: agent.id }],
|
|
}, actor);
|
|
const started = await service.startOAuth(company.id, managed.connectionId, {
|
|
redirectUri: "https://tenant.example/api/tools/oauth/cloud-connector/callback", actor,
|
|
});
|
|
mockToolsList([{ name: "get_me", annotations: { readOnlyHint: true } }]);
|
|
const completed = await service.completePaperclipCloudConnectorCallback({
|
|
state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "asana-claim", actor,
|
|
});
|
|
expect(completed.connection).toMatchObject({ status: "active", enabled: true });
|
|
expect(completed.catalog).toEqual([expect.objectContaining({ toolName: "get_me", status: "active" })]);
|
|
const repeat = await service.finishGalleryAppConnection(company.id, managed.connectionId, {
|
|
enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [],
|
|
access: { agentIds: installed ? [agent.id] : [] },
|
|
}, actor);
|
|
expect(repeat.profile.name).not.toBe(first.profile.name);
|
|
expect(repeat.profileBindings.map((binding) => binding.targetId)).toEqual(installed ? [agent.id] : []);
|
|
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).toHaveLength(2);
|
|
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, first.profile.id)))
|
|
.toEqual([expect.objectContaining({ name: first.profile.name })]);
|
|
});
|
|
|
|
it("resumes personal Asana client credentials with database timestamp precision", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "asana-owner" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user",
|
|
oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" },
|
|
}, actor);
|
|
const before = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
const grant = before.grants[0]!;
|
|
await db.update(connectionGrants).set({ updatedAt: sql`'2026-01-01 12:00:00.123456'::timestamp` })
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user",
|
|
resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-client" },
|
|
}, actor);
|
|
const after = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
expect(after.grants).toHaveLength(1);
|
|
expect(after.grants[0]!.credentialSecretRefs).toEqual(grant.credentialSecretRefs);
|
|
expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]);
|
|
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(true);
|
|
expect((await service.listConnections(company.id, actor.actorId))[0].hasSavedOAuthClientSecret).toBe(true);
|
|
expect((await service.getConnection(connected.connectionId, company.id, "another-user")).hasSavedOAuthClientSecret).toBe(false);
|
|
expect((await service.getConnection(connected.connectionId, company.id)).hasSavedOAuthClientSecret).toBe(false);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id));
|
|
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false);
|
|
await db.update(connectionGrants).set({ status: "active", credentialSecretRefs: [] }).where(eq(connectionGrants.id, grant.id));
|
|
expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false);
|
|
});
|
|
|
|
it("does not retain a customer OAuth secret when the client id changes", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana",
|
|
connectionMethodKey: "mcp-own-oauth",
|
|
name: "Asana own app",
|
|
oauthClient: {
|
|
clientId: "asana-first-client",
|
|
clientSecret: "asana-first-secret",
|
|
},
|
|
});
|
|
|
|
const resumed = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana",
|
|
connectionMethodKey: "mcp-own-oauth",
|
|
name: "Asana own app",
|
|
resumeConnectionId: connected.connectionId,
|
|
oauthClient: {
|
|
clientId: "asana-second-client",
|
|
},
|
|
});
|
|
const [resumedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, resumed.connectionId));
|
|
expect(resumedConnection.credentialSecretRefs).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ configPath: "oauth.client_secret" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("rejects %s setup before creating credentials when memory connectors are disabled", async (provider) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false });
|
|
try {
|
|
const response = await request(createRouteApp(db)).get(`/api/companies/${company.id}/tools/gallery`).expect(200);
|
|
expect(response.body.apps.some((app: { slug: string }) => app.slug === provider)).toBe(false);
|
|
await expect(service.connectGalleryApp(company.id, { galleryKey: provider })).rejects.toMatchObject({ status: 403, details: { code: "memory_connectors_disabled" } });
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
|
|
} finally {
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
|
|
}
|
|
});
|
|
|
|
it("rotates an existing Mem0 key while new memory setup is disabled", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "mem0", credentialValues: { "credentials.authorization": "old-key" },
|
|
});
|
|
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false });
|
|
try {
|
|
const result = await service.reconnectGalleryApp(connected.connectionId, company.id,
|
|
{ credentialValues: { "credentials.authorization": "new-key" } });
|
|
expect(result.connection.id).toBe(connected.connectionId);
|
|
expect(result.connection.healthStatus).toBe("ok");
|
|
} finally {
|
|
await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true });
|
|
}
|
|
});
|
|
|
|
it("keeps active Cognee tools available during a transient Cloud probe outage", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "cognee", credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "key" },
|
|
});
|
|
await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId));
|
|
fetchMock.mockReset().mockRejectedValue(new Error("temporary timeout"));
|
|
const result = await service.checkHealth(connected.connectionId);
|
|
expect(result.connection.healthStatus).toBe("ok");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId));
|
|
expect(catalog).toHaveLength(3);
|
|
});
|
|
|
|
it("creates a user-owned value when reconnect restores a missing personal credential field", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]);
|
|
const actor = { actorType: "user" as const, actorId: "personal-reconnect-owner" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "mem0", grantKind: "user", credentialValues: { "credentials.authorization": "old-key" },
|
|
}, actor);
|
|
const { grants } = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
await db.update(connectionGrants).set({ credentialSecretRefs: [] }).where(eq(connectionGrants.id, grants[0]!.id));
|
|
await service.reconnectGalleryApp(connected.connectionId, company.id,
|
|
{ credentialValues: { "credentials.authorization": "restored-key" } }, actor);
|
|
const after = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
const ref = after.grants[0]!.credentialSecretRefs[0]!;
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
|
|
expect(secret).toMatchObject({ scope: "user", ownerUserId: actor.actorId });
|
|
const resolved = await secretService(db).resolveUserSecretValue(company.id, {
|
|
definitionId: secret.userSecretDefinitionId!, responsibleUserId: actor.actorId,
|
|
}, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath,
|
|
actorType: "system", actorId: null, responsibleUserId: actor.actorId });
|
|
expect(resolved?.value).toBe("restored-key");
|
|
expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]);
|
|
});
|
|
|
|
it("keeps rejected Mem0 API keys on the key-entry path rather than switching to OAuth", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } }));
|
|
await expect(service.connectGalleryApp(company.id, {
|
|
galleryKey: "mem0", credentialValues: { "credentials.authorization": "invalid-key" },
|
|
})).rejects.toMatchObject({ status: 422, details: { code: "memory_api_key_rejected" } });
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("vaults Cognee environment credentials and verifies Cloud access before exposing its reviewed tools", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
|
|
const key = "cognee-private-fixture-key";
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "cognee",
|
|
credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": key },
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith("https://fixture.aws.cognee.ai/api/v1/datasets/", expect.objectContaining({
|
|
method: "GET", headers: { "X-Api-Key": key }, redirect: "manual",
|
|
}));
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.config).toMatchObject({ templateId: "paperclip.cognee-cloud" });
|
|
expect(connection.credentialSecretRefs.map(ref => ref.configPath).sort()).toEqual(["env.COGNEE_API_KEY", "env.COGNEE_BASE_URL"]);
|
|
expect(JSON.stringify(connected)).not.toContain(key);
|
|
expect(JSON.stringify(connection)).not.toContain(key);
|
|
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId));
|
|
expect(catalog.map(entry => [entry.toolName, entry.riskLevel]).sort()).toEqual([["forget", "destructive"], ["recall", "read"], ["remember", "write"]]);
|
|
});
|
|
|
|
it("resolves personal Cognee credentials through their owner and durable declarations", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 }));
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "cognee", grantKind: "user",
|
|
credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "personal-cognee-key" },
|
|
}, { actorType: "user", actorId: "carol" });
|
|
const { grants } = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
expect(grants).toHaveLength(1);
|
|
expect(connected.connection.credentialSecretRefs).toEqual([]);
|
|
const vault = secretService(db);
|
|
for (const ref of grants[0]!.credentialSecretRefs) {
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
|
|
expect(secret).toMatchObject({ scope: "user", ownerUserId: "carol" });
|
|
const resolved = await vault.resolveUserSecretValue(company.id, {
|
|
definitionId: secret.userSecretDefinitionId, responsibleUserId: "carol",
|
|
}, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath,
|
|
actorType: "system", actorId: null, responsibleUserId: "carol" });
|
|
expect(resolved?.value).toBe(ref.configPath === "env.COGNEE_API_KEY" ? "personal-cognee-key" : "https://fixture.aws.cognee.ai");
|
|
await expect(vault.resolveUserSecretValue(company.id, {
|
|
definitionId: secret.userSecretDefinitionId, responsibleUserId: "another-user",
|
|
})).rejects.toMatchObject({ status: 422, details: { code: "user_secret_missing" } });
|
|
}
|
|
});
|
|
|
|
it.each(["organization", "user"] as const)("cleans up a rejected %s Cognee connection and its vault records", async (grantKind) => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401 }));
|
|
await expect(service.connectGalleryApp(company.id, {
|
|
galleryKey: "cognee", grantKind, credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "invalid-key" },
|
|
}, { actorType: "user", actorId: "carol" })).rejects.toMatchObject({ status: 422, details: { code: "cognee_access_unverified" } });
|
|
expect(await db.select().from(userSecretDefinitions).where(eq(userSecretDefinitions.companyId, company.id))).toHaveLength(0);
|
|
expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.companyId, company.id))).toHaveLength(0);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0);
|
|
});
|
|
|
|
it("retains an encrypted API key when the same draft method resumes", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "search_memories", annotations: { readOnlyHint: true } },
|
|
]);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "mem0",
|
|
name: "Mem0 for the company",
|
|
credentialValues: {
|
|
"credentials.authorization": "mem0-test-secret",
|
|
},
|
|
});
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "draft", enabled: false })
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
|
|
const resumed = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "mem0",
|
|
name: "Mem0 for the company",
|
|
resumeConnectionId: connected.connectionId,
|
|
});
|
|
const [resumedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, resumed.connectionId));
|
|
expect(resumedConnection.credentialSecretRefs).toEqual(
|
|
connection.credentialSecretRefs,
|
|
);
|
|
expect(resumedConnection.credentialRefs).toEqual(connection.credentialRefs);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"a confidential token endpoint auth method",
|
|
{
|
|
client_id: "notion-dcr-client",
|
|
token_endpoint_auth_method: "client_secret_basic",
|
|
},
|
|
"token_endpoint_auth_method",
|
|
],
|
|
[
|
|
"a different redirect URI",
|
|
{
|
|
client_id: "notion-dcr-client",
|
|
redirect_uris: ["https://attacker.example/callback"],
|
|
},
|
|
"redirect_uris",
|
|
],
|
|
[
|
|
"a reduced grant set",
|
|
{ client_id: "notion-dcr-client", grant_types: ["authorization_code"] },
|
|
"grant_types",
|
|
],
|
|
[
|
|
"different response types",
|
|
{ client_id: "notion-dcr-client", response_types: ["token"] },
|
|
"response_types",
|
|
],
|
|
["an oversized client id", { client_id: "x".repeat(4_097) }, "client_id"],
|
|
])(
|
|
"rejects DCR responses that return %s",
|
|
async (_label, registrationResponse, field) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "notion",
|
|
name: `Notion invalid DCR ${field}`,
|
|
});
|
|
const redirectUri =
|
|
"https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"
|
|
) {
|
|
return mcpHttpResponse({
|
|
authorization_servers: ["https://mcp.notion.com"],
|
|
});
|
|
}
|
|
if (
|
|
href ===
|
|
"https://mcp.notion.com/.well-known/oauth-authorization-server"
|
|
) {
|
|
return mcpHttpResponse({
|
|
authorization_endpoint: "https://mcp.notion.com/authorize",
|
|
token_endpoint: "https://mcp.notion.com/token",
|
|
registration_endpoint: "https://mcp.notion.com/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
});
|
|
}
|
|
if (href === "https://mcp.notion.com/register") {
|
|
return mcpHttpResponse({
|
|
client_id: "notion-dcr-client",
|
|
redirect_uris: [redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
...registrationResponse,
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await expect(
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri,
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 502,
|
|
details: {
|
|
code: "oauth_dcr_response_invalid",
|
|
field,
|
|
},
|
|
});
|
|
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connected.connectionId));
|
|
expect(connection.ownership).not.toBe("dcr");
|
|
expect(
|
|
(connection.config.oauth as Record<string, unknown> | undefined)
|
|
?.clientId,
|
|
).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("fails fast when Notion DCR is attempted from a non-loopback HTTP origin", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "notion",
|
|
name: "Notion invalid origin",
|
|
});
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
|
|
await expect(
|
|
service.startOAuth(company.id, connected.connectionId, {
|
|
redirectUri: "http://paperclip-dev:3100/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
message:
|
|
"This provider requires an HTTPS or loopback origin. Configure TLS before connecting.",
|
|
details: expect.objectContaining({
|
|
code: "oauth_redirect_origin_unsupported",
|
|
docsPath: "docs/deploy",
|
|
}),
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leases rotating OAuth refresh tokens across service instances before concurrent remote app calls", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const concurrentService = createTestToolAccessService(db);
|
|
|
|
const connect = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "slack",
|
|
name: "Slack refresh",
|
|
});
|
|
const start = await service.startOAuth(company.id, connect.connectionId, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
|
|
let refreshCallCount = 0;
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
if (body.get("grant_type") === "authorization_code") {
|
|
return {
|
|
ok: true,
|
|
json: async () => (oauthToken("old-access-token", "refresh-token")),
|
|
} as Response;
|
|
}
|
|
expect(body.get("grant_type")).toBe("refresh_token");
|
|
expect(body.get("refresh_token")).toBe("refresh-token");
|
|
refreshCallCount += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return {
|
|
ok: true,
|
|
json: async () => (oauthToken("new-access-token", "new-refresh-token")),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "search_messages", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback(oauthCallbackInput(state));
|
|
const [connected] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...connected.config,
|
|
oauth: {
|
|
...(connected.config.oauth as Record<string, unknown>),
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
|
|
const [health, concurrentHealth] = await Promise.all([
|
|
service.checkHealth(connect.connectionId),
|
|
concurrentService.checkHealth(connect.connectionId),
|
|
]);
|
|
|
|
expect(health.connection.healthStatus).toBe("ok");
|
|
expect(concurrentHealth.connection.healthStatus).toBe("ok");
|
|
expect(refreshCallCount).toBe(1);
|
|
const fetchCalls = vi.mocked(globalThis.fetch).mock.calls;
|
|
const mcpCalls = fetchCalls.filter(
|
|
([url]) => String(url) === "https://mcp.slack.com/mcp",
|
|
);
|
|
expect(mcpCalls.at(-1)?.[1]?.headers).toEqual(
|
|
expect.objectContaining({ Authorization: "Bearer new-access-token" }),
|
|
);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
expect(
|
|
Date.parse(
|
|
String((connection.config.oauth as { expiresAt: string }).expiresAt),
|
|
),
|
|
).toBeGreaterThan(Date.now());
|
|
const refreshRef = connection.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
)!;
|
|
const refreshVersions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(eq(companySecretVersions.secretId, refreshRef.secretId));
|
|
expect(refreshVersions).toHaveLength(2);
|
|
expect(refreshVersions.map((version) => version.status).sort()).toEqual([
|
|
"current",
|
|
"previous",
|
|
]);
|
|
const credentialAccessEvents = await db
|
|
.select()
|
|
.from(secretAccessEvents)
|
|
.where(
|
|
and(
|
|
eq(secretAccessEvents.companyId, company.id),
|
|
eq(secretAccessEvents.consumerId, connect.connectionId),
|
|
),
|
|
);
|
|
expect(credentialAccessEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
configPath: "oauth.refresh_token",
|
|
outcome: "success",
|
|
}),
|
|
expect.objectContaining({
|
|
configPath: "oauth.access_token",
|
|
outcome: "success",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("treats invalid_grant as terminal without replaying a rotated refresh token", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const connect = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "slack",
|
|
name: "Slack invalid grant",
|
|
});
|
|
const start = await service.startOAuth(company.id, connect.connectionId, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return mcpHttpResponse(oauthToken("expired-access-token", "single-use-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback(oauthCallbackInput(state));
|
|
const [connected] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...connected.config,
|
|
oauth: {
|
|
...(connected.config.oauth as Record<string, unknown>),
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
let refreshCallCount = 0;
|
|
fetchMock.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("grant_type")).toBe("refresh_token");
|
|
expect(body.get("refresh_token")).toBe("single-use-refresh-token");
|
|
refreshCallCount += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
headers: { get: () => null },
|
|
json: async () => ({
|
|
error: "invalid_grant",
|
|
error_description: "Refresh token was already used",
|
|
}),
|
|
} as unknown as Response;
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const results = await Promise.allSettled([
|
|
service.checkHealth(connect.connectionId),
|
|
service.checkHealth(connect.connectionId),
|
|
]);
|
|
expect(results).toEqual([
|
|
expect.objectContaining({ status: "rejected" }),
|
|
expect.objectContaining({ status: "rejected" }),
|
|
]);
|
|
for (const result of results) {
|
|
expect(result.status === "rejected" ? result.reason : null).toMatchObject(
|
|
{
|
|
details: expect.objectContaining({
|
|
code: "oauth_reauthorization_required",
|
|
}),
|
|
status: 422,
|
|
},
|
|
);
|
|
}
|
|
expect(refreshCallCount).toBe(1);
|
|
const [reauthorizationRequired] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
expect(reauthorizationRequired).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "error",
|
|
});
|
|
expect(
|
|
reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath),
|
|
).not.toContain("oauth.access_token");
|
|
expect(
|
|
reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath),
|
|
).not.toContain("oauth.refresh_token");
|
|
expect(
|
|
reauthorizationRequired.credentialRefs.map((ref) => ref.name),
|
|
).not.toContain("oauth.access_token");
|
|
});
|
|
|
|
it("does not disable a connection when invalid_grant used a superseded refresh-token version", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const connect = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "slack",
|
|
name: "Slack stale invalid grant",
|
|
});
|
|
const start = await service.startOAuth(company.id, connect.connectionId, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return mcpHttpResponse(oauthToken("expired-access-token", "submitted-refresh-token"));
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback(oauthCallbackInput(state));
|
|
const [connected] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
const refreshRef = connected.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
)!;
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...connected.config,
|
|
oauth: {
|
|
...(connected.config.oauth as Record<string, unknown>),
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
|
|
fetchMock.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("refresh_token")).toBe("submitted-refresh-token");
|
|
await secretService(db).rotate(refreshRef.secretId, {
|
|
value: "newer-refresh-token",
|
|
});
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
headers: { get: () => null },
|
|
json: async () => ({ error: "invalid_grant" }),
|
|
} as unknown as Response;
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await expect(
|
|
service.checkHealth(connect.connectionId),
|
|
).rejects.toMatchObject({
|
|
status: 502,
|
|
details: expect.objectContaining({ code: "oauth_refresh_superseded" }),
|
|
});
|
|
const [preserved] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
expect(preserved).toMatchObject({ status: "active", enabled: true });
|
|
expect(preserved.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(
|
|
expect.arrayContaining(["oauth.access_token", "oauth.refresh_token"]),
|
|
);
|
|
expect(
|
|
(preserved.config.oauth as Record<string, unknown>).refreshLease,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("fails closed instead of replaying a refresh token after an abandoned lease", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fixture = await createOAuthConnection(db, company.id);
|
|
const refreshSecret = await secretService(db).create(company.id, {
|
|
provider: "local_encrypted",
|
|
name: `OAuth refresh ${randomUUID()}`,
|
|
key: `oauth.refresh.${randomUUID()}`,
|
|
value: "possibly-consumed-refresh-token",
|
|
});
|
|
await db.insert(companySecretBindings).values({
|
|
companyId: company.id,
|
|
secretId: refreshSecret.id,
|
|
targetType: "tool_connection",
|
|
targetId: fixture.connection.id,
|
|
configPath: "oauth.refresh_token",
|
|
});
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...fixture.connection.config,
|
|
oauth: {
|
|
...(fixture.connection.config.oauth as Record<string, unknown>),
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
refreshLease: {
|
|
id: "abandoned-refresh",
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
},
|
|
credentialSecretRefs: [
|
|
...fixture.connection.credentialSecretRefs,
|
|
{
|
|
secretId: refreshSecret.id,
|
|
versionSelector: "latest",
|
|
configPath: "oauth.refresh_token",
|
|
required: false,
|
|
label: "OAuth refresh token",
|
|
},
|
|
],
|
|
})
|
|
.where(eq(toolConnections.id, fixture.connection.id));
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
|
|
await expect(
|
|
service.checkHealth(fixture.connection.id),
|
|
).rejects.toMatchObject({
|
|
status: 502,
|
|
details: expect.objectContaining({
|
|
code: "oauth_refresh_outcome_unknown",
|
|
}),
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses OAuth client credentials for shared machine-to-machine MCP connections", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_ID", "m2m-client-id");
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_SECRET", "m2m-client-secret");
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Machine OAuth",
|
|
transport: "mcp_remote",
|
|
config: {
|
|
url: "https://m2m.example.test/mcp",
|
|
oauth: {
|
|
provider: "m2m",
|
|
tokenUrl: "https://m2m.example.test/oauth/token",
|
|
grantType: "client_credentials",
|
|
scopes: ["tools.read"],
|
|
},
|
|
},
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://m2m.example.test/oauth/token") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("grant_type")).toBe("client_credentials");
|
|
expect(body.get("client_id")).toBe("m2m-client-id");
|
|
expect(body.get("client_secret")).toBe("m2m-client-secret");
|
|
expect(body.get("scope")).toBe("tools.read");
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
access_token: "m2m-access-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://m2m.example.test/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer m2m-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "machine_read", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const health = await service.checkHealth(connection.id, {
|
|
actorType: "system",
|
|
actorId: "health-check",
|
|
});
|
|
expect(health.connection.healthStatus).toBe("ok");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const [updated] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
expect(updated.credentialSecretRefs).toEqual([
|
|
expect.objectContaining({
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
}),
|
|
]);
|
|
expect(updated.credentialRefs).toEqual([
|
|
expect.objectContaining({
|
|
name: "oauth.access_token",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(updated.config)).not.toContain("m2m-access-token");
|
|
});
|
|
|
|
it.each(["catalog", "catalog/refresh", "health-check"])("returns reconnect instructions on %s for expired OAuth without a refresh token", async (path) => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET",
|
|
"slack-client-secret",
|
|
);
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const connect = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "slack",
|
|
name: "Slack no refresh",
|
|
});
|
|
const start = await service.startOAuth(company.id, connect.connectionId, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url) => {
|
|
const href = String(url);
|
|
if (href === "https://slack.com/api/oauth.v2.access") {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
ok: true,
|
|
access_token: "access-without-refresh",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "search_messages",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback(oauthCallbackInput(state));
|
|
const [connected] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: {
|
|
...connected.config,
|
|
oauth: {
|
|
...(connected.config.oauth as Record<string, unknown>),
|
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
credentialSecretRefs: connected.credentialSecretRefs.filter(
|
|
(ref) => ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
fetchMock.mockClear();
|
|
|
|
await expect(
|
|
service.checkHealth(connect.connectionId, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "oauth_refresh_missing",
|
|
setupUrl: `/apps/${connect.connectionId}/permissions`,
|
|
reconnectUrl: `/apps/${connect.connectionId}/permissions`,
|
|
connection: expect.objectContaining({ healthStatus: "failed" }),
|
|
}),
|
|
});
|
|
await db.delete(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
const app = createRouteApp(db, boardSessionActor(company.id, "owner", "board"));
|
|
const url = `/api/tool-connections/${connect.connectionId}/${path}`;
|
|
const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url));
|
|
expect(response.status).toBe(422);
|
|
expect(response.body).toMatchObject({
|
|
code: "oauth_refresh_missing",
|
|
error: "OAuth credentials have expired and need to be reconnected.",
|
|
details: { setupUrl: `/apps/${connect.connectionId}/permissions`, reconnectUrl: `/apps/${connect.connectionId}/permissions` },
|
|
});
|
|
expect(capture).not.toHaveBeenCalled();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const auditRows = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(
|
|
eq(
|
|
toolAccessAuditEvents.action,
|
|
"tool_connection.credential_resolution",
|
|
),
|
|
);
|
|
const audit = auditRows.find((row) => row.outcome === "failure");
|
|
expect(audit).toMatchObject({
|
|
outcome: "failure",
|
|
reasonCode: "oauth_refresh_missing",
|
|
});
|
|
expect(JSON.stringify(audit)).not.toContain("access-without-refresh");
|
|
});
|
|
|
|
it("returns a callback error when the provider rejects sign-in", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", "operator-user"),
|
|
);
|
|
|
|
const res = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ error: "access_denied", error_description: "User declined" });
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("aggregates app connections needing attention through the board route", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Attention app ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: `Attention connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
transportConfig: { url: "https://fixture.example/mcp" },
|
|
healthStatus: "error",
|
|
healthMessage: "Token revoked.",
|
|
})
|
|
.returning();
|
|
const [ignoredConnection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: `Healthy connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://healthy.example/mcp" },
|
|
transportConfig: { url: "https://healthy.example/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
status: "quarantined",
|
|
versionHash: "v1",
|
|
schemaHash: "s1",
|
|
quarantineReason: "pending_review",
|
|
quarantinedAt: new Date(),
|
|
})
|
|
.returning();
|
|
await db.insert(toolCatalogEntries).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: ignoredConnection.id,
|
|
name: "search",
|
|
toolName: "search",
|
|
riskLevel: "read",
|
|
isReadOnly: true,
|
|
status: "active",
|
|
versionHash: "v1",
|
|
schemaHash: "s1",
|
|
});
|
|
const [invocation] = await db
|
|
.insert(toolInvocations)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "send_email",
|
|
status: "awaiting_approval",
|
|
approvalState: "pending",
|
|
})
|
|
.returning();
|
|
await db.insert(toolActionRequests).values({
|
|
companyId: company.id,
|
|
invocationId: invocation.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "args-hash",
|
|
canonicalArgumentsSummary: { summary: "redacted", redactedFields: [] },
|
|
signedArguments: signToolArguments({
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
canonicalArguments: canonicalToolArguments({ redacted: true }),
|
|
signingSecret: "attention-test-secret",
|
|
}),
|
|
});
|
|
|
|
const res = await request(app).get(
|
|
`/api/companies/${company.id}/tools/apps/attention`,
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.totals).toMatchObject({
|
|
connections: 1,
|
|
health: 1,
|
|
quarantinedCatalogEntries: 1,
|
|
pendingActionRequests: 1,
|
|
});
|
|
expect(res.body.apps).toEqual([
|
|
expect.objectContaining({
|
|
connection: expect.objectContaining({
|
|
id: connection.id,
|
|
healthStatus: "error",
|
|
}),
|
|
healthNeedsAttention: true,
|
|
quarantinedCatalogEntryCount: 1,
|
|
pendingActionRequestCount: 1,
|
|
reasons: [
|
|
"health",
|
|
"quarantined_catalog_entries",
|
|
"pending_action_requests",
|
|
],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("cancels invalid-signature pending action requests but keeps unsigned in-flight ones out of the review queue without cancelling them", async () => {
|
|
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "current-secret");
|
|
const company = await createCompany(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Action review app ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: `Action review connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
})
|
|
.returning();
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "kv_set",
|
|
toolName: "kv_set",
|
|
title: "KV Set",
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
status: "active",
|
|
versionHash: "v1",
|
|
schemaHash: "s1",
|
|
})
|
|
.returning();
|
|
const canonicalArguments = canonicalToolArguments({
|
|
key: "alpha",
|
|
value: "one",
|
|
});
|
|
const invocationValues = [1, 2, 3, 4].map(() => ({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "kv_set",
|
|
argumentsHash: "args-hash",
|
|
argumentsSummary: {
|
|
summary: canonicalArguments,
|
|
sha256: "args-hash",
|
|
sizeBytes: canonicalArguments.length,
|
|
},
|
|
policyDecision: "require_approval" as const,
|
|
approvalState: "pending" as const,
|
|
status: "awaiting_approval" as const,
|
|
}));
|
|
const [
|
|
validInvocation,
|
|
missingSignatureInvocation,
|
|
staleMissingSignatureInvocation,
|
|
oldSecretInvocation,
|
|
] = await db.insert(toolInvocations).values(invocationValues).returning();
|
|
const validSignedArguments = signToolArguments({
|
|
invocationId: validInvocation.id,
|
|
toolName: validInvocation.toolName,
|
|
canonicalArguments,
|
|
signingSecret: "current-secret",
|
|
});
|
|
const oldSecretSignedArguments = signToolArguments({
|
|
invocationId: oldSecretInvocation.id,
|
|
toolName: oldSecretInvocation.toolName,
|
|
canonicalArguments,
|
|
signingSecret: "old-secret",
|
|
});
|
|
const [
|
|
validRequest,
|
|
missingSignatureRequest,
|
|
staleMissingSignatureRequest,
|
|
oldSecretRequest,
|
|
] = await db
|
|
.insert(toolActionRequests)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
invocationId: validInvocation.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "args-hash",
|
|
canonicalArgumentsSummary: {
|
|
summary: canonicalArguments,
|
|
sha256: "args-hash",
|
|
sizeBytes: canonicalArguments.length,
|
|
},
|
|
signedArguments: validSignedArguments,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
invocationId: missingSignatureInvocation.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "args-hash",
|
|
canonicalArgumentsSummary: {
|
|
summary: canonicalArguments,
|
|
sha256: "args-hash",
|
|
sizeBytes: canonicalArguments.length,
|
|
},
|
|
signedArguments: null,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
invocationId: staleMissingSignatureInvocation.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "args-hash",
|
|
canonicalArgumentsSummary: {
|
|
summary: canonicalArguments,
|
|
sha256: "args-hash",
|
|
sizeBytes: canonicalArguments.length,
|
|
},
|
|
signedArguments: null,
|
|
createdAt: new Date(Date.now() - 3 * 60 * 1000),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
invocationId: oldSecretInvocation.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "args-hash",
|
|
canonicalArgumentsSummary: {
|
|
summary: canonicalArguments,
|
|
sha256: "args-hash",
|
|
sizeBytes: canonicalArguments.length,
|
|
},
|
|
signedArguments: oldSecretSignedArguments,
|
|
},
|
|
])
|
|
.returning();
|
|
|
|
const list = await createTestToolAccessService(db).listActionRequests(
|
|
company.id,
|
|
"pending",
|
|
);
|
|
const rows = await db.select().from(toolActionRequests);
|
|
const statusById = new Map(rows.map((row) => [row.id, row.status]));
|
|
|
|
expect(list.map((item) => item.request.id)).toEqual([validRequest.id]);
|
|
expect(statusById.get(validRequest.id)).toBe("pending");
|
|
// An unsigned request is still being created; the read hides it but keeps it
|
|
// pending, so the creator can finish signing and the later approve succeeds.
|
|
expect(statusById.get(missingSignatureRequest.id)).toBe("pending");
|
|
// If the creator never finishes signing, Review retires the stale orphan
|
|
// instead of leaving a permanent badge for a request no human can approve.
|
|
expect(statusById.get(staleMissingSignatureRequest.id)).toBe("cancelled");
|
|
// A request signed with a rotated/old secret is unverifiable and is cancelled.
|
|
expect(statusById.get(oldSecretRequest.id)).toBe("cancelled");
|
|
});
|
|
|
|
it("tracks new profile tools, reviews mixed allow/block decisions, and clears pending counts", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Review app ${randomUUID()}`,
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: `Review connection ${randomUUID()}`,
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://review.example/mcp" },
|
|
transportConfig: { url: "https://review.example/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
const oldSeenAt = new Date("2026-01-01T00:00:00.000Z");
|
|
const profileCreatedAt = new Date("2026-01-02T00:00:00.000Z");
|
|
const newSeenAt = new Date("2026-01-03T00:00:00.000Z");
|
|
const [oldEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "read_email",
|
|
toolName: "read_email",
|
|
title: "Read email",
|
|
description: "Read mailbox messages.",
|
|
riskLevel: "read",
|
|
isReadOnly: true,
|
|
status: "active",
|
|
versionHash: "old-v1",
|
|
schemaHash: "old-s1",
|
|
firstSeenAt: oldSeenAt,
|
|
lastSeenAt: oldSeenAt,
|
|
})
|
|
.returning();
|
|
const [sendEntry, deleteEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
title: "Send email",
|
|
description: "Send outbound messages.",
|
|
riskLevel: "write" as const,
|
|
isReadOnly: false,
|
|
isWrite: true,
|
|
status: "active" as const,
|
|
versionHash: "send-v1",
|
|
schemaHash: "send-s1",
|
|
firstSeenAt: newSeenAt,
|
|
lastSeenAt: newSeenAt,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "delete_email",
|
|
toolName: "delete_email",
|
|
title: "Delete email",
|
|
description: "Delete mailbox messages.",
|
|
riskLevel: "destructive" as const,
|
|
isReadOnly: false,
|
|
isDestructive: true,
|
|
status: "active" as const,
|
|
versionHash: "delete-v1",
|
|
schemaHash: "delete-s1",
|
|
firstSeenAt: newSeenAt,
|
|
lastSeenAt: newSeenAt,
|
|
},
|
|
])
|
|
.returning();
|
|
const [profile] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: company.id,
|
|
profileKey: `review-${randomUUID()}`,
|
|
name: "Read-only starter",
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
createdAt: profileCreatedAt,
|
|
updatedAt: profileCreatedAt,
|
|
})
|
|
.returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: oldEntry.id,
|
|
});
|
|
|
|
const listRes = await request(app).get(
|
|
`/api/companies/${company.id}/tools/profiles`,
|
|
);
|
|
expect(listRes.status).toBe(200);
|
|
expect(listRes.body.profiles).toContainEqual(
|
|
expect.objectContaining({
|
|
id: profile.id,
|
|
newToolsPendingCount: 2,
|
|
}),
|
|
);
|
|
|
|
const detailRes = await request(app).get(
|
|
`/api/tool-profiles/${profile.id}/new-tools`,
|
|
);
|
|
expect(detailRes.status).toBe(200);
|
|
expect(detailRes.body).toMatchObject({
|
|
profileId: profile.id,
|
|
pendingCount: 2,
|
|
tools: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
catalogEntryId: sendEntry.id,
|
|
toolName: "send_email",
|
|
applicationName: application.name,
|
|
connectionName: connection.name,
|
|
capability: "write",
|
|
addedAt: newSeenAt.toISOString(),
|
|
}),
|
|
expect.objectContaining({
|
|
catalogEntryId: deleteEntry.id,
|
|
capability: "destructive",
|
|
}),
|
|
]),
|
|
});
|
|
|
|
const reviewRes = await request(app)
|
|
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
|
|
.send({
|
|
decisions: [
|
|
{ catalogEntryId: sendEntry.id, decision: "allow" },
|
|
{ catalogEntryId: deleteEntry.id, decision: "keep_blocked" },
|
|
],
|
|
});
|
|
|
|
expect(reviewRes.status).toBe(200);
|
|
expect(reviewRes.body).toMatchObject({
|
|
allowedCount: 1,
|
|
keptBlockedCount: 1,
|
|
profile: expect.objectContaining({
|
|
id: profile.id,
|
|
newToolsPendingCount: 0,
|
|
}),
|
|
entriesCreated: [
|
|
expect.objectContaining({
|
|
catalogEntryId: sendEntry.id,
|
|
effect: "include",
|
|
}),
|
|
],
|
|
reviewedCatalogEntryIds: expect.arrayContaining([
|
|
sendEntry.id,
|
|
deleteEntry.id,
|
|
]),
|
|
});
|
|
const profileEntries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, profile.id));
|
|
expect(
|
|
profileEntries.some(
|
|
(entry) =>
|
|
entry.catalogEntryId === sendEntry.id && entry.effect === "include",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
profileEntries.some((entry) => entry.catalogEntryId === deleteEntry.id),
|
|
).toBe(false);
|
|
const [reviewedProfile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
expect(reviewedProfile.newToolsReviewedAt).toBeInstanceOf(Date);
|
|
|
|
const afterReviewRes = await request(app).get(
|
|
`/api/companies/${company.id}/tools/profiles`,
|
|
);
|
|
expect(afterReviewRes.body.profiles).toContainEqual(
|
|
expect.objectContaining({
|
|
id: profile.id,
|
|
newToolsPendingCount: 0,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns addedAt for auto-allowed effective profile tools without pending review state", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "Tool User",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Auto app"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Auto connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://auto.example/mcp" },
|
|
transportConfig: { url: "https://auto.example/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
const addedAt = new Date("2026-02-03T00:00:00.000Z");
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "auto_allowed",
|
|
toolName: "auto_allowed",
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
status: "active",
|
|
versionHash: "auto-v1",
|
|
schemaHash: "auto-s1",
|
|
firstSeenAt: addedAt,
|
|
lastSeenAt: addedAt,
|
|
})
|
|
.returning();
|
|
const [profile] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: company.id,
|
|
profileKey: `auto-${randomUUID()}`,
|
|
name: "Auto allow",
|
|
status: "active",
|
|
defaultAction: "allow",
|
|
})
|
|
.returning();
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
targetType: "company",
|
|
targetId: company.id,
|
|
});
|
|
|
|
const effective = await service.getEffectiveProfilesForAgent(
|
|
company.id,
|
|
agent.id,
|
|
);
|
|
|
|
expect(effective.allowedTools).toContainEqual(
|
|
expect.objectContaining({
|
|
id: catalogEntry.id,
|
|
addedAt,
|
|
firstSeenAt: addedAt,
|
|
}),
|
|
);
|
|
const profiles = await service.listProfiles(company.id);
|
|
expect(
|
|
profiles.find((item) => item.id === profile.id)?.newToolsPendingCount,
|
|
).toBe(0);
|
|
});
|
|
|
|
it("surfaces and clears profile new-tools attention feed items", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Attention review app"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Attention review connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://attention-review.example/mcp" },
|
|
transportConfig: { url: "https://attention-review.example/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
const [oldEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "read_records",
|
|
toolName: "read_records",
|
|
riskLevel: "read",
|
|
isReadOnly: true,
|
|
status: "active",
|
|
versionHash: "read-v1",
|
|
schemaHash: "read-s1",
|
|
firstSeenAt: new Date("2026-03-01T00:00:00.000Z"),
|
|
lastSeenAt: new Date("2026-03-01T00:00:00.000Z"),
|
|
})
|
|
.returning();
|
|
const [newEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "write_records",
|
|
toolName: "write_records",
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
status: "active",
|
|
versionHash: "write-v1",
|
|
schemaHash: "write-s1",
|
|
firstSeenAt: new Date("2026-03-03T00:00:00.000Z"),
|
|
lastSeenAt: new Date("2026-03-03T00:00:00.000Z"),
|
|
})
|
|
.returning();
|
|
const [profile] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: company.id,
|
|
profileKey: `attention-review-${randomUUID()}`,
|
|
name: "Read-only starter",
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
createdAt: new Date("2026-03-02T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-02T00:00:00.000Z"),
|
|
})
|
|
.returning();
|
|
await db.insert(toolProfileEntries).values({
|
|
companyId: company.id,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: oldEntry.id,
|
|
});
|
|
|
|
const attentionRes = await request(app).get(
|
|
`/api/companies/${company.id}/tools/apps/attention`,
|
|
);
|
|
expect(attentionRes.status).toBe(200);
|
|
expect(attentionRes.body.totals).toMatchObject({
|
|
connections: 1,
|
|
newToolsPendingReview: 1,
|
|
newToolsPendingProfiles: 1,
|
|
});
|
|
expect(attentionRes.body.apps).toEqual([
|
|
expect.objectContaining({
|
|
connection: expect.objectContaining({ id: connection.id }),
|
|
newToolsPendingReviewCount: 1,
|
|
newToolsPendingProfiles: [
|
|
expect.objectContaining({
|
|
profileId: profile.id,
|
|
profileName: "Read-only starter",
|
|
pendingCount: 1,
|
|
}),
|
|
],
|
|
reasons: ["profile_new_tools"],
|
|
}),
|
|
]);
|
|
|
|
const reviewRes = await request(app)
|
|
.post(`/api/tool-profiles/${profile.id}/new-tools/review`)
|
|
.send({
|
|
decisions: [{ catalogEntryId: newEntry.id, decision: "keep_blocked" }],
|
|
});
|
|
expect(reviewRes.status).toBe(200);
|
|
|
|
const clearedRes = await request(app).get(
|
|
`/api/companies/${company.id}/tools/apps/attention`,
|
|
);
|
|
expect(clearedRes.body.totals).toMatchObject({
|
|
connections: 0,
|
|
newToolsPendingReview: 0,
|
|
newToolsPendingProfiles: 0,
|
|
});
|
|
expect(clearedRes.body.apps).toEqual([]);
|
|
});
|
|
|
|
it("rolls back app connect drafts when health check fails", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://broken.example/mcp",
|
|
name: "Broken app",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(toolCatalogEntries)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("keeps missing personal authorization as a client-actionable health failure", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
await db
|
|
.delete(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connection.id));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialPolicy: "per_user",
|
|
createdByUserId: "board",
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
|
|
await expect(
|
|
service.checkHealth(connection.id, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "user_authorization_required",
|
|
}),
|
|
});
|
|
|
|
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
|
|
healthStatus: "error",
|
|
healthMessage: "This connection needs the current user's authorization",
|
|
});
|
|
});
|
|
|
|
it("reuses and revives an existing application when connecting with applicationId", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("read_items", "Read items."),
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://reuse.example.test/actions",
|
|
name: "Reusable app",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const applicationId = first.application.id;
|
|
|
|
// Simulate "Remove app": archive the connection and its application.
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "archived" })
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "archived", archivedAt: new Date() })
|
|
.where(eq(toolApplications.id, applicationId));
|
|
|
|
const second = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://reuse.example.test/actions",
|
|
name: "Reusable app",
|
|
applicationId,
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(second.application.id).toBe(applicationId);
|
|
// The archived connection is revived in place, not duplicated.
|
|
expect(second.connectionId).toBe(first.connectionId);
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(1);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
|
|
const [revived] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, applicationId));
|
|
expect(revived.status).toBe("draft");
|
|
expect(revived.archivedAt).toBeNull();
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://reuse.example.test/actions",
|
|
applicationId: randomUUID(),
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it("reuses a removed gallery app while applying the omitted organization identity default", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = { actorType: "user" as const, actorId: "local-board" };
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "notion",
|
|
name: "Notion",
|
|
grantKind: "user",
|
|
},
|
|
actor,
|
|
);
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
|
|
const second = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "notion",
|
|
name: "Notion",
|
|
},
|
|
actor,
|
|
);
|
|
|
|
expect(second.application.id).toBe(first.application.id);
|
|
expect(second.connectionId).toBe(first.connectionId);
|
|
expect(second.application.status).toBe("draft");
|
|
expect(second.connection.status).toBe("draft");
|
|
expect(second.connection.credentialPolicy).toBe("shared");
|
|
const grants = await service.listConnectionGrants(
|
|
second.connectionId,
|
|
company.id,
|
|
);
|
|
expect(grants.grants).toEqual([
|
|
expect.objectContaining({
|
|
kind: "organization",
|
|
status: "active",
|
|
isDefault: true,
|
|
}),
|
|
]);
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(1);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
|
|
});
|
|
|
|
it("restores grants and credential policy when an identity-changing revival fails", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = {
|
|
actorType: "user" as const,
|
|
actorId: "local-board",
|
|
actorSource: "local_implicit" as const,
|
|
};
|
|
const fetchMock = mockToolsList([
|
|
{ name: "get_file_contents", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "organization",
|
|
name: "GitHub rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "old-organization-token",
|
|
},
|
|
},
|
|
actor,
|
|
);
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
const beforeConnection = await service.getConnection(
|
|
first.connectionId,
|
|
company.id,
|
|
);
|
|
const beforeGrants = await service.listConnectionGrants(
|
|
first.connectionId,
|
|
company.id,
|
|
);
|
|
fetchMock.mockRejectedValue(new Error("provider unavailable"));
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "user",
|
|
name: "GitHub rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "new-personal-token",
|
|
},
|
|
},
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
|
|
await expect(
|
|
service.getConnection(first.connectionId, company.id),
|
|
).resolves.toMatchObject({
|
|
status: beforeConnection.status,
|
|
credentialPolicy: beforeConnection.credentialPolicy,
|
|
credentialSecretRefs: beforeConnection.credentialSecretRefs,
|
|
});
|
|
const afterGrants = await service.listConnectionGrants(
|
|
first.connectionId,
|
|
company.id,
|
|
);
|
|
expect(afterGrants.grants).toEqual(beforeGrants.grants);
|
|
});
|
|
|
|
it("preserves a concurrent grant change when an identity-changing revival fails", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = {
|
|
actorType: "user" as const,
|
|
actorId: "local-board",
|
|
actorSource: "local_implicit" as const,
|
|
};
|
|
const fetchMock = mockToolsList([
|
|
{ name: "get_file_contents", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "organization",
|
|
name: "GitHub concurrent rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "old-organization-token",
|
|
},
|
|
},
|
|
actor,
|
|
);
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
fetchMock.mockImplementation(async () => {
|
|
const [personalGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, first.connectionId),
|
|
eq(connectionGrants.kind, "user"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
expect(personalGrant).toBeTruthy();
|
|
const concurrentUpdateAt = new Date(Date.now() + 2_000);
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "revoked",
|
|
credentialSecretRefs: [],
|
|
revokedAt: concurrentUpdateAt,
|
|
updatedAt: concurrentUpdateAt,
|
|
})
|
|
.where(eq(connectionGrants.id, personalGrant!.id));
|
|
throw new Error("provider unavailable");
|
|
});
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "user",
|
|
name: "GitHub concurrent rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "new-personal-token",
|
|
},
|
|
},
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
|
|
await expect(
|
|
service.getConnection(first.connectionId, company.id),
|
|
).resolves.toMatchObject({
|
|
status: "archived",
|
|
credentialPolicy: "shared",
|
|
});
|
|
const afterGrants = await service.listConnectionGrants(
|
|
first.connectionId,
|
|
company.id,
|
|
);
|
|
expect(afterGrants.grants).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ kind: "organization", status: "revoked" }),
|
|
expect.objectContaining({
|
|
kind: "user",
|
|
status: "revoked",
|
|
credentialSecretRefs: [],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("preserves a concurrent connection update when an identity-changing revival fails", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = {
|
|
actorType: "user" as const,
|
|
actorId: "local-board",
|
|
actorSource: "local_implicit" as const,
|
|
};
|
|
const fetchMock = mockToolsList([
|
|
{ name: "get_file_contents", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "organization",
|
|
name: "GitHub concurrent connection rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "old-organization-token",
|
|
},
|
|
},
|
|
actor,
|
|
);
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
fetchMock.mockImplementation(async () => {
|
|
const concurrentUpdateAt = new Date(Date.now() + 2_000);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
config: { ...connection.config, concurrentOAuthCompletion: true },
|
|
transportConfig: {
|
|
...connection.transportConfig,
|
|
concurrentOAuthCompletion: true,
|
|
},
|
|
updatedAt: concurrentUpdateAt,
|
|
})
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
throw new Error("provider unavailable");
|
|
});
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "user",
|
|
name: "GitHub concurrent connection rollback",
|
|
credentialValues: {
|
|
"credentials.authorization": "new-personal-token",
|
|
},
|
|
},
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
|
|
await expect(
|
|
service.getConnection(first.connectionId, company.id),
|
|
).resolves.toMatchObject({
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: "per_user",
|
|
config: expect.objectContaining({ concurrentOAuthCompletion: true }),
|
|
});
|
|
const afterGrants = await service.listConnectionGrants(
|
|
first.connectionId,
|
|
company.id,
|
|
);
|
|
const personalGrant = afterGrants.grants.find(
|
|
(grant) => grant.kind === "user",
|
|
);
|
|
expect(personalGrant).toMatchObject({ status: "active" });
|
|
expect(personalGrant?.credentialSecretRefs).toHaveLength(1);
|
|
const [preservedSecret] = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
eq(companySecrets.id, personalGrant!.credentialSecretRefs[0]!.secretId),
|
|
);
|
|
expect(preservedSecret.deletedAt).toBeNull();
|
|
});
|
|
|
|
it("fails closed when an identity-changing revival cannot roll back", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const actor = {
|
|
actorType: "user" as const,
|
|
actorId: "local-board",
|
|
actorSource: "local_implicit" as const,
|
|
};
|
|
const fetchMock = mockToolsList([
|
|
{ name: "get_file_contents", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "organization",
|
|
name: "GitHub rollback failure",
|
|
credentialValues: {
|
|
"credentials.authorization": "old-organization-token",
|
|
},
|
|
},
|
|
actor,
|
|
);
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
fetchMock.mockRejectedValue(new Error("provider unavailable"));
|
|
await db.execute(sql`create function tool_health_rollback_failure() returns trigger language plpgsql as $$
|
|
begin raise exception 'rollback unavailable'; end $$`);
|
|
await db.execute(sql.raw(`create trigger tool_health_rollback_failure before update on tool_connections
|
|
for each row when (old.id = '${first.connectionId}'::uuid and old.credential_policy = 'per_user' and new.credential_policy = 'shared')
|
|
execute function tool_health_rollback_failure()`));
|
|
try {
|
|
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
grantKind: "user",
|
|
name: "GitHub rollback failure",
|
|
credentialValues: {
|
|
"credentials.authorization": "new-personal-token",
|
|
},
|
|
},
|
|
actor,
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 500,
|
|
details: { code: "connection_identity_rollback_failed" },
|
|
});
|
|
|
|
await expect(
|
|
service.getConnection(first.connectionId, company.id),
|
|
).resolves.toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "error",
|
|
lastError: "connection_identity_rollback_failed",
|
|
});
|
|
} finally {
|
|
await db.execute(sql`drop trigger tool_health_rollback_failure on tool_connections`);
|
|
await db.execute(sql`drop function tool_health_rollback_failure()`);
|
|
}
|
|
});
|
|
|
|
it("automatically gives same-named connections distinct names", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("read_items", "Read items."),
|
|
]);
|
|
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://first.example.test/actions",
|
|
name: "Notion",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const second = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://second.example.test/actions",
|
|
name: "Notion",
|
|
applicationId: first.application.id,
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(second.application.id).toBe(first.application.id);
|
|
expect(second.connectionId).not.toBe(first.connectionId);
|
|
const rows = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.applicationId, first.application.id));
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.map((row) => row.name).sort()).toEqual([
|
|
"Notion",
|
|
"Notion (2)",
|
|
]);
|
|
expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2);
|
|
});
|
|
|
|
it("automatically resolves same-name application races", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("read_items", "Read items."),
|
|
]);
|
|
|
|
const results = await Promise.all([
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://parallel-one.example.test/actions",
|
|
name: "Parallel app",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://parallel-two.example.test/actions",
|
|
name: "Parallel app",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
]);
|
|
|
|
expect(
|
|
new Set(results.map((result) => result.application.id)),
|
|
).toHaveProperty("size", 2);
|
|
const applications = await db
|
|
.select({ name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, company.id));
|
|
expect(applications.map((row) => row.name).sort()).toEqual([
|
|
"Parallel app",
|
|
"Parallel app (2)",
|
|
]);
|
|
});
|
|
|
|
it("does not delete a reused application when the connect rolls back", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("read_items", "Read items."),
|
|
]);
|
|
const first = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://rollback.example.test/actions",
|
|
name: "Rollback app",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "archived" })
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "archived", archivedAt: new Date() })
|
|
.where(eq(toolApplications.id, first.application.id));
|
|
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
|
|
await expect(
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://rollback.example.test/actions",
|
|
applicationId: first.application.id,
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
|
|
const [stillThere] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, first.application.id));
|
|
expect(stillThere).toBeTruthy();
|
|
expect(stillThere.status).toBe("archived");
|
|
const [connectionBack] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
expect(connectionBack.status).toBe("archived");
|
|
});
|
|
|
|
it("connects pasted links with an optional secret-backed app key", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
readTool("read_items", "Read items."),
|
|
]);
|
|
|
|
const connect = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
link: "https://links.example.test/actions",
|
|
name: "Linked app",
|
|
credentialValues: { "credentials.authorization": "link-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://links.example.test/actions",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer link-secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(connect.connection).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
config: {
|
|
url: "https://links.example.test/actions",
|
|
quarantineNewEntries: false,
|
|
},
|
|
credentialSecretRefs: [
|
|
expect.objectContaining({
|
|
configPath: "credentials.authorization",
|
|
label: "App key",
|
|
}),
|
|
],
|
|
});
|
|
expect(JSON.stringify(connect.connection.config)).not.toContain(
|
|
"link-secret",
|
|
);
|
|
await expect(db.select().from(companySecrets)).resolves.toHaveLength(1);
|
|
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it("returns a sign-in-required code when a pasted link answers with an OAuth challenge", async () => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db);
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "www-authenticate"
|
|
? 'Bearer realm="app"'
|
|
: null,
|
|
},
|
|
text: async () => JSON.stringify({ error: "unauthorized" }),
|
|
json: async () => ({}),
|
|
} as Response);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({
|
|
link: "https://signin.example.test/actions",
|
|
name: "Sign-in app",
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({
|
|
error: "This app needs you to sign in.",
|
|
details: expect.objectContaining({ code: "oauth_challenge" }),
|
|
});
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it.each(["catalog", "catalog/refresh", "health-check"])(
|
|
"explains disabled Slack MCP access on %s without reporting a server error",
|
|
async (path) => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `slack-setup-${randomUUID()}`,
|
|
name: "Slack setup fixture",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Slack setup fixture",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
enabled: false,
|
|
config: { url: "https://mcp.slack.com/mcp" },
|
|
transportConfig: { url: "https://mcp.slack.com/mcp" },
|
|
}).returning();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(
|
|
JSON.stringify({ jsonrpc: "2.0", id: null, error: {
|
|
code: -32600,
|
|
message: "App is not enabled for Slack MCP server access. Please enable it here: https://api.slack.com/apps/fixture/mcp",
|
|
} }),
|
|
{ status: 400, headers: { "content-type": "application/json" } },
|
|
));
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
const app = createRouteApp(db);
|
|
const url = `/api/tool-connections/${connection!.id}/${path}`;
|
|
const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url));
|
|
expect(response.status).toBe(422);
|
|
expect(response.body).toMatchObject({
|
|
code: "slack_mcp_access_disabled",
|
|
error: "Slack MCP access is disabled for this app. Ask the Slack app owner to enable MCP access, then refresh this connection.",
|
|
details: { code: "slack_mcp_access_disabled", setupUrl: expect.any(String) },
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain("api.slack.com/apps/fixture");
|
|
expect(capture).not.toHaveBeenCalled();
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
|
|
expect(updated?.healthStatus).toBe("error");
|
|
expect(updated?.healthMessage).toBe(response.body.error);
|
|
await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection!.id)))
|
|
.resolves.toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
(["catalog", "catalog/refresh", "health-check"] as const).flatMap((path) =>
|
|
([401, 403] as const).map((upstreamStatus) => ({ path, upstreamStatus })),
|
|
),
|
|
)(
|
|
"preserves reconnect guidance and degraded health for explicit OAuth scope failure on $path (HTTP $upstreamStatus)",
|
|
async ({ path, upstreamStatus }) => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `scope-status-${randomUUID()}`,
|
|
name: "OAuth scope fixture",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "OAuth scope fixture",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
enabled: false,
|
|
config: { url: "https://scope-status.example.test/mcp" },
|
|
transportConfig: { url: "https://scope-status.example.test/mcp" },
|
|
}).returning();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(
|
|
JSON.stringify({ error: "private provider details" }),
|
|
{ status: upstreamStatus, headers: { "www-authenticate": 'Bearer error="insufficient_scope", scope="tools:read"' } },
|
|
));
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
const app = createRouteApp(db);
|
|
const url = `/api/tool-connections/${connection!.id}/${path}`;
|
|
const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url));
|
|
|
|
expect(response.status).toBe(422);
|
|
expect(response.body).toMatchObject({
|
|
code: "oauth_insufficient_scope",
|
|
error: expect.stringContaining("Reconnect this connection"),
|
|
details: {
|
|
code: "oauth_insufficient_scope",
|
|
setupUrl: expect.any(String),
|
|
reconnectUrl: expect.any(String),
|
|
},
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain("private provider details");
|
|
expect(capture).not.toHaveBeenCalled();
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
|
|
expect(updated?.healthStatus).toBe("degraded");
|
|
expect(updated?.healthMessage).toBe(response.body.error);
|
|
await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id)))
|
|
.resolves.toEqual(expect.arrayContaining([expect.objectContaining({
|
|
action: path === "health-check" ? "tool_connection.health_check" : "tool_connection.catalog_refresh",
|
|
outcome: "failure",
|
|
reasonCode: "oauth_insufficient_scope",
|
|
})]));
|
|
await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection!.id)))
|
|
.resolves.toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["catalog", 401, 'Bearer realm="app"', 422, false],
|
|
["catalog/refresh", 401, 'Bearer realm="app"', 422, false],
|
|
["catalog", 400, null, 502, true],
|
|
["catalog/refresh", 400, null, 502, true],
|
|
["catalog", 503, null, 502, false],
|
|
["catalog/refresh", 503, null, 502, false],
|
|
["catalog/refresh", 403, null, 502, false],
|
|
["health-check", 503, 'Bearer error="insufficient_scope"', 502, false],
|
|
["catalog/refresh", 404, null, 502, false],
|
|
["catalog/refresh", 429, null, 502, false],
|
|
["health-check", 500, null, 502, false],
|
|
] as const)(
|
|
"classifies %s upstream HTTP %i while retaining the failed response",
|
|
async (path, upstreamStatus, challenge, expectedStatus, reportable) => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `catalog-status-${randomUUID()}`,
|
|
name: "Catalog status fixture",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Catalog status fixture",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
enabled: false,
|
|
config: { url: "https://catalog-status.example.test/mcp" },
|
|
transportConfig: { url: "https://catalog-status.example.test/mcp" },
|
|
credentialSecretRefs: [],
|
|
})
|
|
.returning();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
|
|
new Response(JSON.stringify({ error: "upstream request rejected" }), {
|
|
status: upstreamStatus,
|
|
headers: challenge ? { "www-authenticate": challenge } : {},
|
|
}),
|
|
);
|
|
const capture = vi
|
|
.spyOn(sentry, "captureException")
|
|
.mockImplementation(() => {});
|
|
const app = createRouteApp(db);
|
|
const url = `/api/tool-connections/${connection!.id}/${path}`;
|
|
const res = await (path === "catalog"
|
|
? request(app).get(url)
|
|
: request(app).post(url));
|
|
|
|
expect(res.status).toBe(expectedStatus);
|
|
if (challenge && upstreamStatus === 401) {
|
|
expect(res.body).toMatchObject({
|
|
error: "This app needs you to sign in.",
|
|
code: "oauth_challenge",
|
|
details: {
|
|
code: "oauth_challenge",
|
|
setupUrl: expect.any(String),
|
|
reconnectUrl: expect.any(String),
|
|
},
|
|
});
|
|
}
|
|
expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0);
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
|
|
expect(updated?.healthStatus).toBe("error");
|
|
expect(updated?.healthMessage).toBe(res.body.error);
|
|
await expect(
|
|
db.select().from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connection!.id)),
|
|
).resolves.toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["initialize reset", false],
|
|
["initialize parser", true],
|
|
["initialize internal reader", true],
|
|
["notification 503", false],
|
|
["notification 400", true],
|
|
] as const)("preserves session-required MCP failure reporting: %s", async (scenario, reportable) => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id, applicationKey: `session-status-${randomUUID()}`,
|
|
name: "MCP session fixture", type: "mcp_http", status: "active",
|
|
}).returning();
|
|
const config = { url: "https://session-status.example.test/mcp", mcpSessionRequired: true };
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id, applicationId: application!.id, name: "MCP session fixture",
|
|
uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false,
|
|
config, transportConfig: config,
|
|
}).returning();
|
|
const methods: string[] = [];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
|
|
const request = JSON.parse(String(init?.body));
|
|
methods.push(request.method);
|
|
if (request.method === "notifications/initialized") return new Response(null, { status: scenario === "notification 503" ? 503 : 400 });
|
|
expect(request.method).toBe("initialize");
|
|
if (scenario === "initialize parser") return new Response("not JSON");
|
|
if (scenario === "initialize reset" || scenario === "initialize internal reader") {
|
|
const error = scenario === "initialize reset"
|
|
? new TypeError("body terminated", { cause: { code: "ECONNRESET" } })
|
|
: new TypeError("internal reader failed");
|
|
return new Response(new ReadableStream({ start(controller) { controller.error(error); } }));
|
|
}
|
|
return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-06-18" } }));
|
|
});
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`);
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.error).toBeTruthy();
|
|
expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0);
|
|
expect(methods).toEqual(scenario.startsWith("notification") ? ["initialize", "notifications/initialized"] : ["initialize"]);
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
|
|
expect(updated?.healthStatus).toBe("error");
|
|
expect(updated?.healthMessage).toBe(response.body.error);
|
|
await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id)))
|
|
.resolves.toEqual(expect.arrayContaining([expect.objectContaining({ outcome: "failure", action: "tool_connection.catalog_refresh" })]));
|
|
});
|
|
|
|
it.each([
|
|
["known transport", () => { throw new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } }); }, false],
|
|
["direct transport", () => { throw Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); }, false],
|
|
["unknown transport", () => { throw new TypeError("ECONNREFUSED"); }, true],
|
|
["database code", () => { throw Object.assign(new Error("query failed"), { code: "42P01" }); }, true],
|
|
["mixed transport", () => { throw new TypeError("fetch failed", { cause: { errors: [{ code: "ECONNREFUSED" }, { code: "ERR_INTERNAL_ASSERTION" }] } }); }, true],
|
|
["forged classification", () => { throw new HttpError(502, "Remote app returned HTTP 503", { status: 503, connectionFailure: { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason: "remote_unavailable" } }); }, true],
|
|
["malformed response", () => new Response("not JSON"), true],
|
|
["invalid catalog", () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: {} })), true],
|
|
] as const)(
|
|
"preserves MCP diagnostics and reports only unclassified failure: %s",
|
|
async (_name, reply, reportable) => {
|
|
const company = await createCompany(db);
|
|
const [application] = await db.insert(toolApplications).values({
|
|
companyId: company.id, applicationKey: `transport-status-${randomUUID()}`,
|
|
name: "MCP transport fixture", type: "mcp_http", status: "active",
|
|
}).returning();
|
|
const [connection] = await db.insert(toolConnections).values({
|
|
companyId: company.id, applicationId: application!.id, name: "MCP transport fixture",
|
|
uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false,
|
|
config: { url: "https://transport-status.example.test/mcp" },
|
|
transportConfig: { url: "https://transport-status.example.test/mcp" },
|
|
}).returning();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(async () => reply());
|
|
const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {});
|
|
const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`);
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(response.body.error).toBeTruthy();
|
|
expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0);
|
|
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
|
|
expect(updated?.healthStatus).toBe("error");
|
|
expect(updated?.healthMessage).toBe(response.body.error);
|
|
await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id)))
|
|
.resolves.toEqual(expect.arrayContaining([expect.objectContaining({ outcome: "failure", action: "tool_connection.catalog_refresh" })]));
|
|
await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection!.id)))
|
|
.resolves.toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[
|
|
"local_trusted",
|
|
{
|
|
deploymentMode: "local_trusted" as const,
|
|
deploymentExposure: "private" as const,
|
|
},
|
|
],
|
|
[
|
|
"authenticated/private",
|
|
{
|
|
deploymentMode: "authenticated" as const,
|
|
deploymentExposure: "private" as const,
|
|
},
|
|
],
|
|
[
|
|
"authenticated/public",
|
|
{
|
|
deploymentMode: "authenticated" as const,
|
|
deploymentExposure: "public" as const,
|
|
},
|
|
],
|
|
])(
|
|
"rejects OAuth metadata redirects to link-local endpoints in %s",
|
|
async (_label, deployment) => {
|
|
const company = await createCompany(db);
|
|
const app = createRouteApp(db, undefined, undefined, deployment, false);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://8.8.8.8/mcp") {
|
|
return {
|
|
ok: false,
|
|
status: 401,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "www-authenticate"
|
|
? 'Bearer resource_metadata="https://8.8.8.8/.well-known/oauth-protected-resource"'
|
|
: null,
|
|
},
|
|
text: async () => "",
|
|
json: async () => ({}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://8.8.8.8/.well-known/oauth-protected-resource") {
|
|
expect(init?.redirect).toBe("manual");
|
|
return {
|
|
ok: false,
|
|
status: 302,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "location"
|
|
? "http://169.254.169.254/oauth"
|
|
: null,
|
|
},
|
|
json: async () => ({}),
|
|
} as Response;
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({ link: "https://8.8.8.8/mcp", name: "Redirect OAuth MCP" });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://8.8.8.8/.well-known/oauth-protected-resource",
|
|
expect.objectContaining({ redirect: "manual" }),
|
|
);
|
|
expect(
|
|
fetchMock.mock.calls.some(([url]) =>
|
|
String(url).startsWith("http://169.254.169.254"),
|
|
),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("discovers OAuth for pasted MCP links and completes sign-in without a gallery entry", async () => {
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_ID",
|
|
"generic-client-id",
|
|
);
|
|
vi.stubEnv(
|
|
"PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET",
|
|
"generic-client-secret",
|
|
);
|
|
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board-user", [
|
|
"tools:manage_connections",
|
|
]);
|
|
const app = createRouteApp(db);
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://generic.example.test/mcp") {
|
|
return {
|
|
ok: false,
|
|
status: 401,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "www-authenticate"
|
|
? 'Bearer resource_metadata="https://generic.example.test/.well-known/oauth-protected-resource"'
|
|
: null,
|
|
},
|
|
text: async () => "",
|
|
json: async () => ({}),
|
|
} as Response;
|
|
}
|
|
if (
|
|
href ===
|
|
"https://generic.example.test/.well-known/oauth-protected-resource"
|
|
) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
authorization_endpoint:
|
|
"https://generic.example.test/oauth/authorize",
|
|
token_endpoint: "https://generic.example.test/oauth/token",
|
|
scopes_supported: ["tools.read", "tools.write"],
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://generic.example.test/oauth/token") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("grant_type")).toBe("authorization_code");
|
|
expect(body.get("client_id")).toBe("generic-client-id");
|
|
expect(body.get("client_secret")).toBe("generic-client-secret");
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
access_token: "generic-access-token",
|
|
refresh_token: "generic-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
scope: "tools.read tools.write",
|
|
}),
|
|
} as Response;
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const connectRes = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
|
.send({
|
|
link: "https://generic.example.test/mcp",
|
|
name: "Generic OAuth MCP",
|
|
});
|
|
|
|
expect(connectRes.status).toBe(201);
|
|
expect(connectRes.body.auth).toMatchObject({ kind: "oauth" });
|
|
const startUrl = new URL(connectRes.body.auth.startUrl);
|
|
expect(`${startUrl.origin}${startUrl.pathname}`).toBe(
|
|
"https://generic.example.test/oauth/authorize",
|
|
);
|
|
expect(startUrl.searchParams.get("client_id")).toBe("generic-client-id");
|
|
expect(startUrl.searchParams.get("scope")).toBe("tools.read tools.write");
|
|
const state = startUrl.searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
// A generic MCP connection has no caller-supplied scopes: the consent screen is asked for
|
|
// whatever discovery advertised. Persist that, because it is the only baseline the callback
|
|
// has for judging what the provider comes back with.
|
|
const [pendingState] = await db
|
|
.select()
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.state, state!));
|
|
expect(pendingState.requestedScopes).toEqual(["tools.read", "tools.write"]);
|
|
expect(connectRes.body.connection.config.oauth).toMatchObject({
|
|
provider: "generic_example_test",
|
|
tokenUrl: "https://generic.example.test/oauth/token",
|
|
grantType: "authorization_code",
|
|
});
|
|
|
|
fetchMock.mockImplementation(async (url, init) => {
|
|
const href = String(url);
|
|
if (href === "https://generic.example.test/oauth/token") {
|
|
const body = init?.body as URLSearchParams;
|
|
expect(body.get("code")).toBe("generic-code");
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
access_token: "generic-access-token",
|
|
refresh_token: "generic-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://generic.example.test/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer generic-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "read_generic", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
const callbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({ state, code: "generic-code" });
|
|
|
|
expect(callbackRes.status).toBe(200);
|
|
expect(callbackRes.body.catalog).toEqual([
|
|
expect.objectContaining({ toolName: "read_generic", riskLevel: "read" }),
|
|
]);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connectRes.body.connectionId));
|
|
expect(connection.config).toMatchObject({
|
|
oauth: expect.objectContaining({
|
|
provider: "generic_example_test",
|
|
credentialScope: expect.objectContaining({ type: "user" }),
|
|
}),
|
|
});
|
|
expect(JSON.stringify(connection.config)).not.toContain(
|
|
"generic-access-token",
|
|
);
|
|
// This provider omitted `scope` from the token response, so the discovered scopes the
|
|
// authorization URL asked for are what gets recorded — not an empty list.
|
|
expect(
|
|
(connection.config.providerMetadata as { oauth?: Record<string, unknown> })
|
|
?.oauth,
|
|
).toMatchObject({
|
|
scopeSource: "requested_fallback",
|
|
unrequestedScopes: [],
|
|
});
|
|
const [genericGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.connectionId, connectRes.body.connectionId));
|
|
expect(genericGrant.providerTenant?.oauth).toMatchObject({
|
|
scopes: ["tools.read", "tools.write"],
|
|
scopeSource: "requested_fallback",
|
|
});
|
|
});
|
|
|
|
it("blocks Smoke Lab OAuth issuer URLs from the normal tool OAuth secret pipeline", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const smokeAuthorizeUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/authorize`;
|
|
const smokeTokenUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/token`;
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `smoke-oauth-masquerade-${randomUUID()}`,
|
|
name: "Smoke OAuth masquerade",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Smoke OAuth masquerade connection",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: false,
|
|
healthStatus: "unchecked",
|
|
config: {
|
|
url: "http://127.0.0.1:3100/mcp",
|
|
oauth: {
|
|
provider: "smoke_lab",
|
|
authorizationUrl: smokeAuthorizeUrl,
|
|
tokenUrl: smokeTokenUrl,
|
|
scopes: ["repo", "user:email", "offline_access"],
|
|
},
|
|
},
|
|
transportConfig: { url: "http://127.0.0.1:3100/mcp" },
|
|
credentialSecretRefs: [],
|
|
credentialRefs: [],
|
|
})
|
|
.returning();
|
|
|
|
await expect(
|
|
service.startOAuth(company.id, connection!.id, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
message: "Smoke Lab OAuth provider cannot be used for tool app sign-in",
|
|
});
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
|
|
|
|
await db.insert(toolOauthStates).values({
|
|
state: "legacy-smoke-state",
|
|
companyId: company.id,
|
|
connectionId: connection!.id,
|
|
codeVerifier: "legacy-smoke-code-verifier",
|
|
createdByActorType: "user",
|
|
createdByActorId: "board",
|
|
createdBySessionId: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
});
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockRejectedValue(
|
|
new Error("smoke OAuth token endpoint must not be called"),
|
|
);
|
|
|
|
await expect(
|
|
service.completeOAuthCallback({
|
|
state: "legacy-smoke-state",
|
|
code: "smoke-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
message: "Smoke Lab OAuth provider cannot be used for tool app sign-in",
|
|
});
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
const [updatedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection!.id));
|
|
expect(updatedConnection!.credentialSecretRefs).toEqual([]);
|
|
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(
|
|
0,
|
|
);
|
|
await expect(db.select().from(companySecrets)).resolves.toHaveLength(0);
|
|
});
|
|
|
|
it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => {
|
|
const company = await createCompany(db);
|
|
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
|
|
const service = createTestToolAccessService(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: "paperclip.smoke-lab.http-fixture",
|
|
name: "Smoke Lab HTTP MCP fixture",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application!.id,
|
|
name: "Smoke Lab HTTP MCP fixture",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "ok",
|
|
config: {
|
|
smokeLabFixture: "oauth-http",
|
|
url: "http://smoke-fixture.test/mcp",
|
|
oauth: {
|
|
provider: "smoke_lab",
|
|
smokeLabFixture: true,
|
|
scopes: ["smoke:openid", "smoke:profile", "smoke:email"],
|
|
},
|
|
},
|
|
transportConfig: {},
|
|
credentialSecretRefs: [],
|
|
credentialRefs: [],
|
|
})
|
|
.returning();
|
|
|
|
const result = await service.startOAuth(company.id, connection!.id, {
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
const authorizationUrl = new URL(result.authorizationUrl);
|
|
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe(
|
|
`http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/authorize`,
|
|
);
|
|
expect(authorizationUrl.searchParams.get("client_id")).toBe(
|
|
"paperclip-smoke-lab",
|
|
);
|
|
expect(authorizationUrl.searchParams.get("scope")).toBe(
|
|
"smoke:openid smoke:profile smoke:email",
|
|
);
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1);
|
|
|
|
const state = authorizationUrl.searchParams.get("state");
|
|
expect(state).toBeTruthy();
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation(async (url) => {
|
|
if (String(url).endsWith("/smoke-lab/oauth/token")) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
access_token: "smoke-access-token",
|
|
refresh_token: "smoke-refresh-token",
|
|
token_type: "Bearer",
|
|
scope: "smoke:openid smoke:profile smoke:email",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (String(url) === "http://smoke-fixture.test/mcp") {
|
|
return mcpHttpResponse(catalogResponse([
|
|
{ name: "todo.list", annotations: { readOnlyHint: true } },
|
|
]));
|
|
}
|
|
throw new Error(`unexpected fetch ${String(url)}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback({
|
|
state: state!,
|
|
code: "smoke-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
|
|
expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain(
|
|
`http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/token`,
|
|
);
|
|
expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain(
|
|
"http://smoke-fixture.test/mcp",
|
|
);
|
|
const [updatedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection!.id));
|
|
expect(updatedConnection).toMatchObject({ enabled: true });
|
|
expect(updatedConnection!.config).toMatchObject({
|
|
oauth: expect.objectContaining({ connectedAt: expect.any(String) }),
|
|
});
|
|
});
|
|
|
|
it("discovers GitHub Actions tools during PAT setup and legacy catalog refresh", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "actions_list", annotations: { readOnlyHint: true } },
|
|
{
|
|
name: "actions_run_trigger",
|
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
},
|
|
]);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
const connected = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
credentialValues: { "credentials.authorization": "github-actions-test-token" },
|
|
}, actor);
|
|
expect(connected.catalog).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "actions_list", riskLevel: "read" }),
|
|
expect.objectContaining({ toolName: "actions_run_trigger", riskLevel: "destructive" }),
|
|
]));
|
|
|
|
const setupRequestCount = fetchMock.mock.calls.length;
|
|
expect(setupRequestCount).toBeGreaterThan(0);
|
|
await db.update(toolConnections).set({
|
|
config: sql`${toolConnections.config} - 'sourceTemplateKey'`,
|
|
}).where(eq(toolConnections.id, connected.connectionId));
|
|
await service.refreshCatalog(connected.connectionId, actor);
|
|
expect(fetchMock.mock.calls.length).toBeGreaterThan(setupRequestCount);
|
|
for (const [url, init] of fetchMock.mock.calls) {
|
|
expect(url).toBe("https://api.githubcopilot.com/mcp/");
|
|
expect(new Headers(init?.headers).get("X-MCP-Toolsets")).toBe("default,actions");
|
|
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer github-actions-test-token");
|
|
}
|
|
expect(JSON.stringify(connected)).not.toContain("github-actions-test-token");
|
|
});
|
|
|
|
it("connects gallery apps and finishes access profiles, bindings, and ask-first policies", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
readTool("list_zaps", "List Zapier actions."),
|
|
{
|
|
name: "update_zap",
|
|
description: "Update a Zapier action.",
|
|
inputSchema: { type: "object", properties: { id: { type: "string" } } },
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `App Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
|
|
const connect = await withGalleryServerUrl(
|
|
"github",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
name: "GitHub workspace",
|
|
credentialValues: { "credentials.authorization": "zap-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
"mcp-key",
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer zap-secret",
|
|
}),
|
|
}),
|
|
);
|
|
expect(connect.connection).toMatchObject({
|
|
status: "draft",
|
|
enabled: false,
|
|
config: expect.objectContaining({
|
|
sourceTemplateKey: "github",
|
|
quarantineNewEntries: false,
|
|
}),
|
|
credentialSecretRefs: [
|
|
expect.objectContaining({
|
|
configPath: "credentials.authorization",
|
|
label: "GitHub token",
|
|
}),
|
|
],
|
|
});
|
|
expect(connect.actions.readOnly).toEqual([
|
|
expect.objectContaining({ toolName: "list_zaps", riskLevel: "read" }),
|
|
]);
|
|
expect(connect.actions.canMakeChanges).toEqual([
|
|
expect.objectContaining({ toolName: "update_zap", riskLevel: "write" }),
|
|
]);
|
|
|
|
const listEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "list_zaps",
|
|
)!;
|
|
const updateEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "update_zap",
|
|
)!;
|
|
expect(connect.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: listEntry.id,
|
|
status: "active",
|
|
quarantineReason: null,
|
|
}),
|
|
expect.objectContaining({
|
|
id: updateEntry.id,
|
|
status: "active",
|
|
quarantineReason: null,
|
|
}),
|
|
]),
|
|
);
|
|
const finish = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(finish.connection).toMatchObject({
|
|
id: connect.connectionId,
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
expect(finish.profile).toMatchObject({
|
|
profileKey: `app:${connect.connectionId}`,
|
|
defaultAction: "deny",
|
|
status: "active",
|
|
});
|
|
expect(finish.profileEntries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
selectorType: "catalog_entry",
|
|
catalogEntryId: listEntry.id,
|
|
effect: "include",
|
|
}),
|
|
expect.objectContaining({
|
|
selectorType: "catalog_entry",
|
|
catalogEntryId: updateEntry.id,
|
|
effect: "include",
|
|
}),
|
|
]),
|
|
);
|
|
expect(finish.profileBindings).toEqual([
|
|
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
|
|
]);
|
|
expect(finish.policies).toEqual([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
enabled: true,
|
|
selectors: { catalogEntryId: updateEntry.id },
|
|
}),
|
|
]);
|
|
|
|
const repeatFinish = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id, agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(repeatFinish.profile.id).toBe(finish.profile.id);
|
|
expect(repeatFinish.profileEntries).toHaveLength(2);
|
|
expect(repeatFinish.profileBindings).toEqual([
|
|
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
|
|
]);
|
|
expect(repeatFinish.policies).toEqual([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
enabled: true,
|
|
selectors: { catalogEntryId: updateEntry.id },
|
|
}),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, finish.profile.id)),
|
|
).resolves.toHaveLength(1);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(eq(toolPolicies.companyId, company.id)),
|
|
).resolves.toHaveLength(1);
|
|
|
|
const finishedCatalog = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
expect(finishedCatalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: listEntry.id,
|
|
status: "active",
|
|
reviewedAt: expect.any(Date),
|
|
quarantineReason: null,
|
|
}),
|
|
expect.objectContaining({
|
|
id: updateEntry.id,
|
|
status: "active",
|
|
reviewedAt: expect.any(Date),
|
|
quarantineReason: null,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: { ...connect.connection.config, quarantineNewEntries: true },
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List Zapier actions.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "update_zap",
|
|
description: "Update a Zapier action with new args.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
label: { type: "string" },
|
|
},
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "create_zap",
|
|
description: "Create a Zapier action.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { label: { type: "string" } },
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
])),
|
|
);
|
|
const rereview = await service.refreshCatalog(connect.connectionId, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
expect(rereview.quarantinedCount).toBe(2);
|
|
expect(rereview.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
|
|
expect.objectContaining({
|
|
toolName: "update_zap",
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "create_zap",
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const [policy] = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(eq(toolPolicies.companyId, company.id));
|
|
expect(policy).toMatchObject({
|
|
policyType: "require_approval",
|
|
selectors: { catalogEntryId: updateEntry.id },
|
|
config: expect.objectContaining({
|
|
source: "app_gallery_finish",
|
|
connectionId: connect.connectionId,
|
|
catalogEntryId: updateEntry.id,
|
|
}),
|
|
});
|
|
|
|
const createEntry = rereview.catalog.find(
|
|
(entry) => entry.toolName === "create_zap",
|
|
)!;
|
|
await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const stillQuarantined = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
expect(stillQuarantined).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: updateEntry.id, status: "quarantined" }),
|
|
expect.objectContaining({ id: createEntry.id, status: "quarantined" }),
|
|
]),
|
|
);
|
|
|
|
await expect(
|
|
service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, createEntry.id],
|
|
askFirstCatalogEntryIds: [createEntry.id],
|
|
reviewedCatalogEntryIds: [createEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 400,
|
|
message:
|
|
"Action review decisions must cover every currently quarantined action exactly once",
|
|
});
|
|
|
|
const reviewed = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, createEntry.id],
|
|
askFirstCatalogEntryIds: [createEntry.id],
|
|
reviewedCatalogEntryIds: [updateEntry.id, createEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
expect(reviewed.profileEntries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ catalogEntryId: listEntry.id }),
|
|
expect.objectContaining({ catalogEntryId: createEntry.id }),
|
|
]),
|
|
);
|
|
expect(reviewed.profileEntries).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ catalogEntryId: updateEntry.id }),
|
|
]),
|
|
);
|
|
const reviewedCatalog = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
expect(reviewedCatalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: updateEntry.id,
|
|
status: "active",
|
|
reviewedAt: expect.any(Date),
|
|
quarantineReason: null,
|
|
}),
|
|
expect.objectContaining({
|
|
id: createEntry.id,
|
|
status: "active",
|
|
reviewedAt: expect.any(Date),
|
|
quarantineReason: null,
|
|
}),
|
|
]),
|
|
);
|
|
const attentionAfterReview = await service.listAppsNeedingAttention(
|
|
company.id,
|
|
);
|
|
expect(attentionAfterReview.apps).toEqual([]);
|
|
});
|
|
|
|
it("enables newly discovered tools after setup while preserving tools explicitly turned off", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
|
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
|
]);
|
|
|
|
const connect = await withGalleryServerUrl(
|
|
"zapier",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "zapier",
|
|
credentialValues: { "credentials.authorization": "zap-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
);
|
|
const listEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "list_zaps",
|
|
)!;
|
|
const updateEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "update_zap",
|
|
)!;
|
|
await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id],
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const [defaultProfile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connect.connectionId}`));
|
|
expect(defaultProfile).toBeTruthy();
|
|
fetchMock.mockResolvedValueOnce(
|
|
mcpHttpResponse(catalogResponse([
|
|
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
|
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
|
{ name: "create_zap", annotations: { readOnlyHint: false } },
|
|
])),
|
|
);
|
|
|
|
const refresh = await service.refreshCatalog(connect.connectionId, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
});
|
|
|
|
expect(refresh.quarantinedCount).toBe(0);
|
|
expect(refresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "list_zaps", status: "active" }),
|
|
expect.objectContaining({ toolName: "update_zap", status: "active" }),
|
|
expect.objectContaining({ toolName: "create_zap", status: "active" }),
|
|
]),
|
|
);
|
|
const createEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "create_zap",
|
|
)!;
|
|
const profileEntries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, defaultProfile!.id));
|
|
expect(profileEntries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ catalogEntryId: listEntry.id }),
|
|
expect.objectContaining({ catalogEntryId: createEntry.id }),
|
|
]),
|
|
);
|
|
expect(profileEntries).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ catalogEntryId: updateEntry.id }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("restores every action as Allowed when a removed app connection is connected again", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{ name: "list_zaps", annotations: { readOnlyHint: true } },
|
|
{ name: "update_zap", annotations: { readOnlyHint: false } },
|
|
]);
|
|
const actor = { actorType: "user" as const, actorId: "board" };
|
|
|
|
const first = await withGalleryServerUrl(
|
|
"zapier",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "zapier",
|
|
name: "Zapier reconnect defaults",
|
|
credentialValues: { "credentials.authorization": "first-secret" },
|
|
},
|
|
actor,
|
|
),
|
|
);
|
|
const readEntry = first.catalog.find(
|
|
(entry) => entry.toolName === "list_zaps",
|
|
)!;
|
|
const writeEntry = first.catalog.find(
|
|
(entry) => entry.toolName === "update_zap",
|
|
)!;
|
|
const legacy = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
first.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [writeEntry.id],
|
|
askFirstCatalogEntryIds: [writeEntry.id],
|
|
access: "all_agents",
|
|
},
|
|
actor,
|
|
);
|
|
|
|
// A gateway reference forces removal to retain the now-archived profile
|
|
// row, matching the production state that originally exposed this bug.
|
|
await db.insert(toolMcpGateways).values({
|
|
companyId: company.id,
|
|
name: `Retained gateway ${randomUUID()}`,
|
|
slug: `retained-${randomUUID()}`,
|
|
profileId: legacy.profile.id,
|
|
status: "active",
|
|
});
|
|
await service.archiveConnection(first.connectionId, company.id, actor);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.id, legacy.profile.id)),
|
|
).resolves.toEqual([expect.objectContaining({ status: "archived" })]);
|
|
|
|
const connectedAgain = await withGalleryServerUrl(
|
|
"zapier",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "zapier",
|
|
name: "Zapier reconnect defaults",
|
|
credentialValues: { "credentials.authorization": "second-secret" },
|
|
},
|
|
actor,
|
|
),
|
|
);
|
|
|
|
expect(connectedAgain.connectionId).toBe(first.connectionId);
|
|
const [restoredProfile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${first.connectionId}`));
|
|
expect(restoredProfile).toMatchObject({
|
|
id: legacy.profile.id,
|
|
status: "active",
|
|
});
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, restoredProfile!.id)),
|
|
).resolves.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
catalogEntryId: readEntry.id,
|
|
effect: "include",
|
|
}),
|
|
expect.objectContaining({
|
|
catalogEntryId: writeEntry.id,
|
|
effect: "include",
|
|
}),
|
|
]),
|
|
);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, restoredProfile!.id)),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ targetType: "company", targetId: company.id }),
|
|
]);
|
|
await expect(
|
|
db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
),
|
|
).resolves.toEqual([]);
|
|
|
|
// OAuth activates the connection before the Test tab asks the policy
|
|
// engine for these decisions. This fixture uses a key-based gallery app to
|
|
// keep the reconnect setup deterministic, so mirror that final lifecycle
|
|
// transition here.
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ status: "active", enabled: true })
|
|
.where(eq(toolConnections.id, connectedAgain.connectionId));
|
|
const policy = toolAccessPolicyService(db);
|
|
for (const entry of [readEntry, writeEntry]) {
|
|
await expect(
|
|
policy.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: first.connectionId,
|
|
catalogEntryId: entry.id,
|
|
toolName: entry.toolName,
|
|
arguments: {},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
|
|
const company = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `app-gallery:notion:${randomUUID()}`,
|
|
name: "Notion workspace",
|
|
type: "mcp_http",
|
|
status: "draft",
|
|
metadata: { sourceTemplateKey: "notion", galleryKey: "notion" },
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Notion workspace",
|
|
uid: `notion/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
status: "draft",
|
|
enabled: false,
|
|
config: {
|
|
url: "https://mcp.notion.com/mcp",
|
|
sourceTemplateKey: "notion",
|
|
quarantineNewEntries: true,
|
|
},
|
|
transportConfig: { url: "https://mcp.notion.com/mcp" },
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
mockToolsList([
|
|
{ name: "notion-fetch", annotations: { readOnlyHint: true } },
|
|
// These two mutations do not contain a generic create/update verb.
|
|
{ name: "notion-move-pages" },
|
|
{ name: "notion-duplicate-page", annotations: { readOnlyHint: true } },
|
|
]);
|
|
|
|
const refresh = await service.refreshCatalog(connection.id);
|
|
const fetchEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "notion-fetch",
|
|
)!;
|
|
const moveEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "notion-move-pages",
|
|
)!;
|
|
const duplicateEntry = refresh.catalog.find(
|
|
(entry) => entry.toolName === "notion-duplicate-page",
|
|
)!;
|
|
expect(refresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: fetchEntry.id,
|
|
riskLevel: "read",
|
|
isReadOnly: true,
|
|
}),
|
|
expect.objectContaining({
|
|
id: moveEntry.id,
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
}),
|
|
expect.objectContaining({
|
|
id: duplicateEntry.id,
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
}),
|
|
]),
|
|
);
|
|
|
|
// A narrower profile must not make an app shared with "All agents"
|
|
// disappear. App action selection is an additive capability assignment;
|
|
// ordinary profile precedence still governs non-app defaults.
|
|
const existingAgentProfile = await service.createProfile(company.id, {
|
|
profileKey: `existing-agent-profile-${randomUUID()}`,
|
|
name: "Existing agent defaults",
|
|
defaultAction: "deny",
|
|
});
|
|
await service.bindProfile(
|
|
existingAgentProfile.id,
|
|
{ targetType: "agent", targetId: agent.id },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
await expect(
|
|
service.finishGalleryAppConnection(otherCompany.id, connection.id, {
|
|
enabledCatalogEntryIds: [fetchEntry.id],
|
|
askFirstCatalogEntryIds: [],
|
|
access: "all_agents",
|
|
}),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
await service.finishGalleryAppConnection(company.id, connection.id, {
|
|
enabledCatalogEntryIds: [fetchEntry.id, moveEntry.id, duplicateEntry.id],
|
|
askFirstCatalogEntryIds: [moveEntry.id, duplicateEntry.id],
|
|
access: "all_agents",
|
|
});
|
|
|
|
const policyService = toolAccessPolicyService(db);
|
|
const decide = (catalogEntryId: string, toolName: string) =>
|
|
policyService.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id },
|
|
request: {
|
|
connectionId: connection.id,
|
|
catalogEntryId,
|
|
toolName,
|
|
arguments: {},
|
|
},
|
|
});
|
|
await expect(
|
|
decide(fetchEntry.id, fetchEntry.toolName),
|
|
).resolves.toMatchObject({
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
});
|
|
await expect(
|
|
decide(moveEntry.id, moveEntry.toolName),
|
|
).resolves.toMatchObject({
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
});
|
|
await expect(
|
|
decide(duplicateEntry.id, duplicateEntry.toolName),
|
|
).resolves.toMatchObject({
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
});
|
|
|
|
await expect(
|
|
policyService.decide({
|
|
companyId: otherCompany.id,
|
|
actor: { actorType: "user", actorId: "other-board" },
|
|
request: {
|
|
connectionId: connection.id,
|
|
catalogEntryId: fetchEntry.id,
|
|
toolName: fetchEntry.toolName,
|
|
arguments: {},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
decision: "deny",
|
|
reasonCode: "deny_missing_tool",
|
|
});
|
|
});
|
|
|
|
it("rolls back gallery app finish when a later write fails after clearing profile state", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
readTool("list_zaps", "List Zapier actions."),
|
|
{
|
|
name: "update_zap",
|
|
description: "Update a Zapier action.",
|
|
inputSchema: { type: "object", properties: { id: { type: "string" } } },
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Rollback Agent ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
|
|
const connect = await withGalleryServerUrl(
|
|
"github",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
name: "GitHub rollback",
|
|
credentialValues: { "credentials.authorization": "github-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
);
|
|
const listEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "list_zaps",
|
|
)!;
|
|
const updateEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "update_zap",
|
|
)!;
|
|
const firstFinish = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const entriesBefore = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, firstFinish.profile.id));
|
|
const bindingsBefore = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, firstFinish.profile.id));
|
|
const policiesBefore = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
);
|
|
|
|
// An absent actor trips the binding's foreign key after profile state is
|
|
// replaced. Profile-name collisions are now resolved during finalization.
|
|
await expect(
|
|
service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
},
|
|
{ actorType: "agent", actorId: randomUUID() },
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
const entriesAfter = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, firstFinish.profile.id));
|
|
const bindingsAfter = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, firstFinish.profile.id));
|
|
const policiesAfter = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
);
|
|
|
|
expect(entriesAfter.map((entry) => entry.catalogEntryId).sort()).toEqual(
|
|
entriesBefore.map((entry) => entry.catalogEntryId).sort(),
|
|
);
|
|
expect(
|
|
bindingsAfter
|
|
.map((binding) => `${binding.targetType}:${binding.targetId}`)
|
|
.sort(),
|
|
).toEqual(
|
|
bindingsBefore
|
|
.map((binding) => `${binding.targetType}:${binding.targetId}`)
|
|
.sort(),
|
|
);
|
|
expect(policiesAfter.map((policy) => policy.id).sort()).toEqual(
|
|
policiesBefore.map((policy) => policy.id).sort(),
|
|
);
|
|
});
|
|
|
|
it("reconnects a gallery app by rotating the existing credential in place (PAP-10859)", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const fetchMock = mockToolsList([
|
|
readTool("list_zaps", "List"),
|
|
{
|
|
name: "update_zap",
|
|
description: "Update",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
]);
|
|
|
|
const connect = await withGalleryServerUrl(
|
|
"github",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
name: "GitHub reconnect",
|
|
credentialValues: { "credentials.authorization": "old-secret" },
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
);
|
|
|
|
const before = await service.getConnection(
|
|
connect.connectionId,
|
|
company.id,
|
|
);
|
|
const beforeRef = before.credentialSecretRefs.find(
|
|
(r) => r.configPath === "credentials.authorization",
|
|
)!;
|
|
expect(beforeRef).toBeDefined();
|
|
|
|
const listEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "list_zaps",
|
|
)!;
|
|
const updateEntry = connect.catalog.find(
|
|
(entry) => entry.toolName === "update_zap",
|
|
)!;
|
|
const finished = await service.finishGalleryAppConnection(
|
|
company.id,
|
|
connect.connectionId,
|
|
{
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: "all_agents",
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, finished.profile.id));
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
status: "quarantined",
|
|
quarantineReason: "pending_review",
|
|
quarantinedAt: new Date(),
|
|
})
|
|
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: { ...before.config, quarantineNewEntries: true },
|
|
transportConfig: {
|
|
...before.transportConfig,
|
|
quarantineNewEntries: true,
|
|
},
|
|
})
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
fetchMock.mockResolvedValue(
|
|
mcpHttpResponse(catalogResponse([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "update_zap",
|
|
description: "Update",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "delete_zap",
|
|
description: "Delete",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { destructiveHint: true },
|
|
},
|
|
])),
|
|
);
|
|
|
|
await expect(
|
|
service.reconnectGalleryApp(
|
|
connect.connectionId,
|
|
company.id,
|
|
{ credentialValues: {} },
|
|
{ actorType: "user", actorId: "board" },
|
|
),
|
|
).rejects.toMatchObject({
|
|
message: expect.stringContaining("Enter a replacement credential"),
|
|
});
|
|
|
|
const result = await service.reconnectGalleryApp(
|
|
connect.connectionId,
|
|
company.id,
|
|
{ credentialValues: { "credentials.authorization": "new-secret" } },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
expect(result.connection.id).toBe(connect.connectionId);
|
|
|
|
const after = await service.getConnection(connect.connectionId, company.id);
|
|
const afterRef = after.credentialSecretRefs.find(
|
|
(r) => r.configPath === "credentials.authorization",
|
|
)!;
|
|
// Rotated in place: same secret, no duplicate ref created.
|
|
expect(after.credentialSecretRefs).toHaveLength(
|
|
before.credentialSecretRefs.length,
|
|
);
|
|
expect(afterRef.secretId).toBe(beforeRef.secretId);
|
|
expect(after.config).toMatchObject({ quarantineNewEntries: false });
|
|
expect(after.transportConfig).toMatchObject({
|
|
quarantineNewEntries: false,
|
|
});
|
|
|
|
const catalogAfterReconnect = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connect.connectionId));
|
|
expect(catalogAfterReconnect).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: listEntry.id,
|
|
status: "active",
|
|
quarantineReason: null,
|
|
}),
|
|
expect.objectContaining({
|
|
id: updateEntry.id,
|
|
status: "active",
|
|
quarantineReason: null,
|
|
}),
|
|
expect.objectContaining({
|
|
toolName: "delete_zap",
|
|
status: "active",
|
|
riskLevel: "destructive",
|
|
}),
|
|
]),
|
|
);
|
|
const deleteEntry = catalogAfterReconnect.find(
|
|
(entry) => entry.toolName === "delete_zap",
|
|
)!;
|
|
const profileEntriesAfterReconnect = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, finished.profile.id));
|
|
expect(profileEntriesAfterReconnect).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
catalogEntryId: listEntry.id,
|
|
effect: "include",
|
|
}),
|
|
expect.objectContaining({
|
|
catalogEntryId: updateEntry.id,
|
|
effect: "include",
|
|
}),
|
|
expect.objectContaining({
|
|
catalogEntryId: deleteEntry.id,
|
|
effect: "include",
|
|
}),
|
|
]),
|
|
);
|
|
const policiesAfterReconnect = await db
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
),
|
|
);
|
|
// Reconnect preserves explicit policy choices, but newly discovered
|
|
// actions start Allowed just like actions from a fresh connection.
|
|
expect(policiesAfterReconnect).toHaveLength(1);
|
|
expect(policiesAfterReconnect).toEqual([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
selectors: { catalogEntryId: updateEntry.id },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("orders static-key reconnect with concurrent personal grant revocation", async () => {
|
|
const company = await createCompany(db);
|
|
await createAgent(db, company.id);
|
|
const userId = `key-reconnect-${randomUUID()}`;
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([readTool("list_zaps", "List")]);
|
|
await withGalleryServerUrl("github", PUBLIC_MCP_FIXTURE_URL, async () => {
|
|
const connected = await service.connectGalleryApp(company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", name: "Key reconnect fixture", grantKind: "user", credentialValues: { "credentials.authorization": "fixture-old-key" } }, actor);
|
|
const { grants } = await service.listConnectionGrants(connected.connectionId, company.id);
|
|
const grant = grants.find(row => row.kind === "user")!;
|
|
let locked!: () => void; let resume!: () => void; let started!: (pid: number) => void;
|
|
const held = new Promise<void>(resolve => { locked = resolve; });
|
|
const mayRevoke = new Promise<void>(resolve => { resume = resolve; });
|
|
const reconnectStarted = new Promise<number>(resolve => { started = resolve; });
|
|
const revocation = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
await tx.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)).for("update");
|
|
locked(); await mayRevoke;
|
|
return createTestToolAccessService(tx as unknown as Db).revokeConnectionGrant(connected.connectionId, grant.id, actor);
|
|
});
|
|
await held;
|
|
const reconnect = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
started(Number(row.pid));
|
|
return createTestToolAccessService(tx as unknown as Db).reconnectGalleryApp(connected.connectionId, company.id, { credentialValues: { "credentials.authorization": "fixture-new-key" } }, actor);
|
|
});
|
|
const completed = Promise.allSettled([revocation, reconnect]);
|
|
try {
|
|
const pid = await reconnectStarted;
|
|
await vi.waitFor(async () => {
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
} finally { resume(); }
|
|
const results = await completed;
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
const [updatedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id));
|
|
expect(updatedGrant.status).toBe("active");
|
|
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0]!.secretId));
|
|
expect(secret.latestVersion).toBe(2);
|
|
});
|
|
}, 15000);
|
|
|
|
it("reconnects a personal key on the existing user grant without creating an organization credential", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const userId = `personal-key-${randomUUID()}`;
|
|
mockToolsList([
|
|
readTool("list_zaps", "List"),
|
|
]);
|
|
|
|
const connected = await withGalleryServerUrl(
|
|
"github",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
name: "Personal GitHub reconnect",
|
|
grantKind: "user",
|
|
credentialValues: {
|
|
"credentials.authorization": "old-personal-secret",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
),
|
|
);
|
|
const before = await service.getConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const beforeGrants = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const beforePersonalGrant = beforeGrants.grants.find(
|
|
(grant) => grant.kind === "user",
|
|
)!;
|
|
|
|
expect(before).toMatchObject({
|
|
credentialPolicy: "per_user",
|
|
credentialSecretRefs: [],
|
|
});
|
|
expect(beforePersonalGrant).toMatchObject({
|
|
subjectUserId: userId,
|
|
status: "active",
|
|
});
|
|
expect(
|
|
beforeGrants.grants.some((grant) => grant.kind === "organization"),
|
|
).toBe(false);
|
|
const beforeSecretId =
|
|
beforePersonalGrant.credentialSecretRefs[0]!.secretId;
|
|
|
|
await withGalleryServerUrl("github", PUBLIC_MCP_FIXTURE_URL, () =>
|
|
service.reconnectGalleryApp(
|
|
connected.connectionId,
|
|
company.id,
|
|
{
|
|
credentialValues: {
|
|
"credentials.authorization": "new-personal-secret",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
),
|
|
);
|
|
|
|
const after = await service.getConnection(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const afterGrants = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
const afterPersonalGrant = afterGrants.grants.find(
|
|
(grant) => grant.kind === "user",
|
|
)!;
|
|
expect(after).toMatchObject({
|
|
credentialPolicy: "per_user",
|
|
credentialSecretRefs: [],
|
|
});
|
|
expect(afterPersonalGrant).toMatchObject({
|
|
subjectUserId: userId,
|
|
status: "active",
|
|
});
|
|
expect(afterPersonalGrant.credentialSecretRefs[0]!.secretId).toBe(
|
|
beforeSecretId,
|
|
);
|
|
expect(
|
|
afterGrants.grants.some((grant) => grant.kind === "organization"),
|
|
).toBe(false);
|
|
|
|
await service.archiveConnection(connected.connectionId, company.id, {
|
|
actorType: "user",
|
|
actorId: userId,
|
|
});
|
|
const revived = await withGalleryServerUrl(
|
|
"github",
|
|
PUBLIC_MCP_FIXTURE_URL,
|
|
() =>
|
|
service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
applicationId: connected.application.id,
|
|
galleryKey: "github",
|
|
connectionMethodKey: "mcp-key",
|
|
name: "Personal GitHub reconnect",
|
|
// No grantKind is sent on reconnect: the retained connection owns that
|
|
// decision and must reactivate this same grant rather than insert a new
|
|
// one or fall back to an organization credential.
|
|
credentialValues: {
|
|
"credentials.authorization": "revived-personal-secret",
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: userId },
|
|
),
|
|
);
|
|
|
|
expect(revived.connectionId).toBe(connected.connectionId);
|
|
expect(revived.connection).toMatchObject({
|
|
credentialPolicy: "per_user",
|
|
credentialSecretRefs: [],
|
|
});
|
|
const revivedGrants = await service.listConnectionGrants(
|
|
connected.connectionId,
|
|
company.id,
|
|
);
|
|
expect(
|
|
revivedGrants.grants.filter((grant) => grant.kind === "user"),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
id: beforePersonalGrant.id,
|
|
subjectUserId: userId,
|
|
status: "active",
|
|
}),
|
|
]);
|
|
expect(
|
|
revivedGrants.grants.some((grant) => grant.kind === "organization"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stops and restarts local stdio runtime slots through the board service", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
now: () => new Date("2026-06-06T01:00:00.000Z"),
|
|
});
|
|
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Restartable local fixture",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const health = await service.checkHealth(connection.id);
|
|
expect(health.runtimeSlot).toMatchObject({
|
|
connectionId: connection.id,
|
|
status: "stopped",
|
|
runtimeKind: "local_stdio",
|
|
});
|
|
|
|
const restarted = await service.restartRuntimeSlot(
|
|
company.id,
|
|
health.runtimeSlot!.id,
|
|
{
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
},
|
|
);
|
|
expect(restarted).toMatchObject({
|
|
id: health.runtimeSlot!.id,
|
|
status: "running",
|
|
runtimeKind: "local_stdio",
|
|
healthStatus: "ok",
|
|
});
|
|
expect(restarted.providerRef).toMatch(/^local-stdio:/);
|
|
|
|
const stopped = await service.stopRuntimeSlot(
|
|
company.id,
|
|
health.runtimeSlot!.id,
|
|
{
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
},
|
|
);
|
|
expect(stopped).toMatchObject({
|
|
id: health.runtimeSlot!.id,
|
|
status: "stopped",
|
|
healthMessage: "Runtime slot stopped.",
|
|
});
|
|
|
|
const activities = await db.select().from(activityLog);
|
|
expect(activities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
action: "tool_runtime_slot.operator_restarted",
|
|
entityId: health.runtimeSlot!.id,
|
|
}),
|
|
expect.objectContaining({
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
action: "tool_runtime_slot.operator_stopped",
|
|
entityId: health.runtimeSlot!.id,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("exposes board runtime slot stop and restart endpoints", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Route local fixture",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const health = await service.checkHealth(connection.id);
|
|
const slotId = health.runtimeSlot!.id;
|
|
|
|
const restart = await request(app)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`,
|
|
)
|
|
.send({});
|
|
|
|
expect(restart.status).toBe(200);
|
|
expect(restart.body).toMatchObject({
|
|
id: slotId,
|
|
companyId: company.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "running",
|
|
});
|
|
|
|
const stop = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
|
|
.send({});
|
|
|
|
expect(stop.status).toBe(200);
|
|
expect(stop.body).toMatchObject({
|
|
id: slotId,
|
|
companyId: company.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "stopped",
|
|
});
|
|
});
|
|
|
|
it("requires tools:manage_runtime for company-scoped runtime slot routes", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const userId = `runtime-operator-${randomUUID()}`;
|
|
await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
const actor = boardSessionActor(company.id, "operator", userId);
|
|
const app = createRouteApp(db, actor);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Permissioned local fixture",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const health = await service.checkHealth(connection.id);
|
|
const slotId = health.runtimeSlot!.id;
|
|
|
|
await request(app)
|
|
.get(`/api/companies/${company.id}/tools/runtime-slots`)
|
|
.expect(403);
|
|
await request(app)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`,
|
|
)
|
|
.send({})
|
|
.expect(403);
|
|
await request(app)
|
|
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
|
|
.send({})
|
|
.expect(403);
|
|
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
permissionKey: "tools:manage_runtime",
|
|
scope: null,
|
|
grantedByUserId: "owner",
|
|
});
|
|
|
|
const list = await request(app)
|
|
.get(`/api/companies/${company.id}/tools/runtime-slots`)
|
|
.expect(200);
|
|
expect(list.body.runtimeSlots).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: slotId, runtimeKind: "local_stdio" }),
|
|
]),
|
|
);
|
|
|
|
const restart = await request(app)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`,
|
|
)
|
|
.send({})
|
|
.expect(200);
|
|
expect(restart.body).toMatchObject({
|
|
id: slotId,
|
|
companyId: company.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "running",
|
|
});
|
|
|
|
const stop = await request(app)
|
|
.post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`)
|
|
.send({})
|
|
.expect(200);
|
|
expect(stop.body).toMatchObject({
|
|
id: slotId,
|
|
companyId: company.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "stopped",
|
|
});
|
|
});
|
|
|
|
it("updates tool applications through the board route and records activity", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Editable app",
|
|
description: "Before",
|
|
type: "mcp_http",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/tool-applications/${application.id}`)
|
|
.send({ name: "Edited app", description: "After" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
id: application.id,
|
|
companyId: company.id,
|
|
name: "Edited app",
|
|
description: "After",
|
|
type: "mcp_http",
|
|
});
|
|
const activities = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, application.id));
|
|
expect(activities).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_application.updated",
|
|
companyId: company.id,
|
|
details: expect.objectContaining({ name: "Edited app" }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns 409 instead of 500 when an application update collides with a duplicate name", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
await service.createApplication(company.id, {
|
|
name: "Existing app",
|
|
type: "mcp_http",
|
|
});
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Editable app",
|
|
type: "mcp_http",
|
|
});
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/tool-applications/${application.id}`)
|
|
.send({ name: "Existing app" });
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({
|
|
error: "A tool access record with that name already exists",
|
|
});
|
|
});
|
|
|
|
it("returns 404 for cross-company application updates and missing applications", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const application = await createTestToolAccessService(db).createApplication(
|
|
otherCompany.id,
|
|
{
|
|
name: "Other company app",
|
|
type: "mcp_http",
|
|
},
|
|
);
|
|
const app = createRouteApp(db, memberActor(allowedCompany, false));
|
|
|
|
const forbiddenRes = await request(app)
|
|
.patch(`/api/tool-applications/${application.id}`)
|
|
.send({ name: "Forbidden edit" });
|
|
const missingRes = await request(createRouteApp(db))
|
|
.patch(`/api/tool-applications/${randomUUID()}`)
|
|
.send({ name: "Missing edit" });
|
|
|
|
expect(forbiddenRes.status).toBe(404);
|
|
expect(missingRes.status).toBe(404);
|
|
});
|
|
|
|
it("keeps direct application and connection mutation routes viewer-safe", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Viewer guarded app",
|
|
type: "mcp_http",
|
|
});
|
|
const connection = await service.createConnection(company.id, {
|
|
applicationId: application.id,
|
|
name: "Viewer guarded connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://viewer-guard.example/mcp" },
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer", "viewer-user"),
|
|
);
|
|
|
|
const responses = [
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/applications`)
|
|
.send({ name: "Viewer create app", type: "mcp_http" }),
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/connections`)
|
|
.send({
|
|
name: "Viewer create connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://viewer-create.example/mcp" },
|
|
}),
|
|
await request(viewerApp)
|
|
.patch(`/api/tool-applications/${application.id}`)
|
|
.send({ name: "Viewer edited app" }),
|
|
await request(viewerApp).delete(
|
|
`/api/tool-applications/${application.id}`,
|
|
),
|
|
await request(viewerApp)
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ name: "Viewer edited connection" }),
|
|
await request(viewerApp).delete(`/api/tool-connections/${connection.id}`),
|
|
await request(viewerApp)
|
|
.post(`/api/tool-connections/${connection.id}/health-check`)
|
|
.send({}),
|
|
await request(viewerApp)
|
|
.post(`/api/tool-connections/${connection.id}/catalog/refresh`)
|
|
.send({}),
|
|
];
|
|
|
|
for (const res of responses) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Viewer access is read-only");
|
|
}
|
|
});
|
|
|
|
it("does not expose another user's draft connection to a regular member", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Shared OAuth app",
|
|
type: "mcp_http",
|
|
});
|
|
const otherDraft = await service.createConnection(
|
|
company.id,
|
|
{
|
|
applicationId: application.id,
|
|
name: "Other user's draft",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
status: "draft",
|
|
config: { url: "https://other-draft.example/mcp" },
|
|
},
|
|
{ actorType: "user", actorId: "other-user" },
|
|
);
|
|
const ownDraft = await service.createConnection(
|
|
company.id,
|
|
{
|
|
applicationId: application.id,
|
|
name: "Member's draft",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
status: "draft",
|
|
config: { url: "https://own-draft.example/mcp" },
|
|
},
|
|
{ actorType: "user", actorId: "member-user" },
|
|
);
|
|
const activeConnection = await service.createConnection(
|
|
company.id,
|
|
{
|
|
applicationId: application.id,
|
|
name: "Active connection",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://active.example/mcp" },
|
|
},
|
|
{ actorType: "user", actorId: "other-user" },
|
|
);
|
|
|
|
const memberApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "member", "member-user"),
|
|
);
|
|
const memberRes = await request(memberApp)
|
|
.get(`/api/companies/${company.id}/tools/connections`)
|
|
.expect(200);
|
|
|
|
expect(memberRes.body.connections).toHaveLength(2);
|
|
expect(memberRes.body.connections).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: ownDraft.id }),
|
|
expect.objectContaining({ id: activeConnection.id }),
|
|
]),
|
|
);
|
|
expect(memberRes.body.connections).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: otherDraft.id })]),
|
|
);
|
|
|
|
const ownerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "owner", "owner-user"),
|
|
);
|
|
const ownerRes = await request(ownerApp)
|
|
.get(`/api/companies/${company.id}/tools/connections`)
|
|
.expect(200);
|
|
expect(ownerRes.body.connections).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: otherDraft.id }),
|
|
expect.objectContaining({ id: ownDraft.id }),
|
|
expect.objectContaining({ id: activeConnection.id }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("keeps direct profile and policy mutation routes viewer-safe", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
const profile = await service.createProfile(company.id, {
|
|
profileKey: `viewer-guarded-profile-${randomUUID()}`,
|
|
name: "Viewer guarded profile",
|
|
defaultAction: "deny",
|
|
});
|
|
const entry = await service.addProfileEntry(profile.id, {
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "read_notes",
|
|
});
|
|
await service.bindProfile(
|
|
profile.id,
|
|
{ targetType: "agent", targetId: agent.id },
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
const [firstPolicy, secondPolicy] = await db
|
|
.insert(toolPolicies)
|
|
.values([
|
|
{
|
|
companyId: company.id,
|
|
name: `Viewer guarded allow ${randomUUID()}`,
|
|
policyType: "allow",
|
|
priority: 100,
|
|
selectors: { toolName: "read_notes" },
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
name: `Viewer guarded block ${randomUUID()}`,
|
|
policyType: "block",
|
|
priority: 200,
|
|
selectors: { toolName: "delete_notes" },
|
|
},
|
|
])
|
|
.returning();
|
|
const viewerApp = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "viewer", "viewer-user"),
|
|
);
|
|
|
|
await request(viewerApp)
|
|
.get(`/api/companies/${company.id}/tools/profiles`)
|
|
.expect(200);
|
|
await request(viewerApp)
|
|
.get(`/api/companies/${company.id}/tools/policies`)
|
|
.expect(200);
|
|
|
|
const responses = [
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/profiles`)
|
|
.send({
|
|
profileKey: `viewer-created-profile-${randomUUID()}`,
|
|
name: "Viewer created profile",
|
|
defaultAction: "deny",
|
|
}),
|
|
await request(viewerApp)
|
|
.patch(`/api/tool-profiles/${profile.id}`)
|
|
.send({ name: "Viewer edited profile" }),
|
|
await request(viewerApp)
|
|
.post(`/api/tool-profiles/${profile.id}/entries`)
|
|
.send({
|
|
selectorType: "tool_name",
|
|
effect: "include",
|
|
toolName: "viewer_tool",
|
|
}),
|
|
await request(viewerApp)
|
|
.patch(`/api/tool-profile-entries/${entry.id}`)
|
|
.send({ effect: "exclude" }),
|
|
await request(viewerApp).delete(`/api/tool-profile-entries/${entry.id}`),
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/profiles/${profile.id}/bind`)
|
|
.send({ targetType: "agent", targetId: agent.id, priority: 10 }),
|
|
await request(viewerApp)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/profiles/${profile.id}/unbind`,
|
|
)
|
|
.send({ targetType: "agent", targetId: agent.id }),
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/policies/reorder`)
|
|
.send({ policyIds: [secondPolicy!.id, firstPolicy!.id] }),
|
|
await request(viewerApp)
|
|
.post(`/api/companies/${company.id}/tools/policies`)
|
|
.send({
|
|
name: "Viewer policy",
|
|
policyType: "allow",
|
|
selectors: { toolName: "viewer_tool" },
|
|
}),
|
|
await request(viewerApp)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}/duplicate`,
|
|
)
|
|
.send({ name: "Viewer policy copy" }),
|
|
await request(viewerApp)
|
|
.patch(`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`)
|
|
.send({ enabled: false }),
|
|
await request(viewerApp).delete(
|
|
`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`,
|
|
),
|
|
await request(viewerApp)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/action-requests/${randomUUID()}/trust-rule`,
|
|
)
|
|
.send({ name: "Viewer trust rule" }),
|
|
await request(viewerApp)
|
|
.post(
|
|
`/api/companies/${company.id}/tools/trust-rules/${firstPolicy!.id}/revoke`,
|
|
)
|
|
.send({ reason: "viewer revoke" }),
|
|
await request(viewerApp).post(
|
|
`/api/companies/${company.id}/tools/examples/safe-read-only-todo-kv/install`,
|
|
),
|
|
];
|
|
|
|
for (const res of responses) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toContain("Viewer access is read-only");
|
|
}
|
|
});
|
|
|
|
it("deletes an application with zero connections and records activity", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Deletable app",
|
|
type: "mcp_http",
|
|
});
|
|
|
|
const res = await request(app).delete(
|
|
`/api/tool-applications/${application.id}`,
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
id: application.id,
|
|
name: "Deletable app",
|
|
});
|
|
const remaining = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, application.id));
|
|
expect(remaining).toHaveLength(0);
|
|
const activities = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, application.id));
|
|
expect(activities).toEqual([
|
|
expect.objectContaining({
|
|
action: "tool_application.deleted",
|
|
companyId: company.id,
|
|
details: expect.objectContaining({
|
|
name: "Deletable app",
|
|
type: "mcp_http",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns 409 and keeps the application when it still has connections", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Guarded connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
});
|
|
|
|
const res = await request(app).delete(
|
|
`/api/tool-applications/${connection.applicationId}`,
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(String(res.body.error)).toMatch(/connection/i);
|
|
const remaining = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
expect(remaining).toHaveLength(1);
|
|
});
|
|
|
|
it("archives the application when its last connection is removed", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Single connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
|
|
const res = await request(app).delete(
|
|
`/api/tool-connections/${connection.id}`,
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
id: connection.id,
|
|
status: "archived",
|
|
enabled: false,
|
|
});
|
|
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
expect(application).toMatchObject({ status: "archived" });
|
|
expect(application?.archivedAt).toBeInstanceOf(Date);
|
|
|
|
const activities = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.companyId, company.id));
|
|
expect(activities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
action: "tool_connection.archived",
|
|
entityId: connection.id,
|
|
}),
|
|
expect.objectContaining({
|
|
action: "tool_application.archived",
|
|
entityId: connection.applicationId,
|
|
details: expect.objectContaining({
|
|
reason: "last_connection_removed",
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("keeps the application active when another connection remains", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const application = await service.createApplication(company.id, {
|
|
name: "Shared app",
|
|
type: "mcp_http",
|
|
});
|
|
const first = await service.createConnection(company.id, {
|
|
applicationId: application.id,
|
|
name: "First connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://one.example/mcp" },
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
await service.createConnection(company.id, {
|
|
applicationId: application.id,
|
|
name: "Second connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://two.example/mcp" },
|
|
status: "active",
|
|
enabled: true,
|
|
});
|
|
|
|
const res = await request(app).delete(`/api/tool-connections/${first.id}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
const [remainingApplication] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, application.id));
|
|
expect(remainingApplication).toMatchObject({
|
|
status: "active",
|
|
archivedAt: null,
|
|
});
|
|
const activities = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.entityId, application.id));
|
|
expect(
|
|
activities.some(
|
|
(activity) => activity.action === "tool_application.archived",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("keeps normalized connection UIDs unique", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const firstApplication = await service.createApplication(company.id, {
|
|
name: "First UID app",
|
|
type: "mcp_http",
|
|
});
|
|
const secondApplication = await service.createApplication(company.id, {
|
|
name: "Second UID app",
|
|
type: "mcp_http",
|
|
});
|
|
|
|
const first = await service.createConnection(company.id, {
|
|
applicationId: firstApplication.id,
|
|
name: "Foo Bar",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://one.example/mcp" },
|
|
});
|
|
const second = await service.createConnection(company.id, {
|
|
applicationId: secondApplication.id,
|
|
name: "foo-bar",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://two.example/mcp" },
|
|
});
|
|
|
|
expect(first.uid).not.toBe(second.uid);
|
|
expect(first.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/);
|
|
expect(second.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/);
|
|
});
|
|
|
|
it("fails closed at the database when a connection races an application delete (no silent cascade)", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Racy connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
});
|
|
|
|
// Simulate the delete-vs-create race: skip the endpoint's "any connections?" pre-check and
|
|
// issue the raw DELETE it would run afterwards, standing in for a connection created in the
|
|
// gap. Under the old ON DELETE CASCADE schema this silently removed the linked connection;
|
|
// the hardened ON DELETE NO ACTION FK must reject it so the delete can never become an
|
|
// implicit cascade.
|
|
await expect(
|
|
db
|
|
.delete(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId)),
|
|
).rejects.toThrow();
|
|
|
|
const remainingApp = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const remainingConnection = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
expect(remainingApp).toHaveLength(1);
|
|
expect(remainingConnection).toHaveLength(1);
|
|
});
|
|
|
|
it("still cascades application + connection deletes when the owning company is removed", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Company-scoped connection",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
});
|
|
|
|
// NO ACTION (not RESTRICT) must keep the company teardown cascade intact: deleting the
|
|
// company cascades to both tool_applications and tool_connections in one statement, and the
|
|
// end-of-statement FK check passes because the connection is already gone. RESTRICT would
|
|
// abort this delete mid-cascade.
|
|
await db.delete(companies).where(eq(companies.id, company.id));
|
|
|
|
const remainingApp = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const remainingConnection = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
expect(remainingApp).toHaveLength(0);
|
|
expect(remainingConnection).toHaveLength(0);
|
|
});
|
|
|
|
it("returns 404 for cross-company application deletes and missing applications", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const application = await createTestToolAccessService(db).createApplication(
|
|
otherCompany.id,
|
|
{
|
|
name: "Other company app",
|
|
type: "mcp_http",
|
|
},
|
|
);
|
|
const app = createRouteApp(db, memberActor(allowedCompany, false));
|
|
|
|
const forbiddenRes = await request(app).delete(
|
|
`/api/tool-applications/${application.id}`,
|
|
);
|
|
const missingRes = await request(createRouteApp(db)).delete(
|
|
`/api/tool-applications/${randomUUID()}`,
|
|
);
|
|
|
|
expect(forbiddenRes.status).toBe(404);
|
|
expect(missingRes.status).toBe(404);
|
|
const stillThere = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, application.id));
|
|
expect(stillThere).toHaveLength(1);
|
|
});
|
|
|
|
it("links run tool decisions to invocations, audit events, and pending action requests", async () => {
|
|
const company = await createCompany(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: `Tool runner ${randomUUID()}`,
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [issue] = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId: company.id,
|
|
title: `Tool approval ${randomUUID()}`,
|
|
status: "in_progress",
|
|
})
|
|
.returning();
|
|
const [run] = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Governed tools"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Remote MCP",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://example.invalid/mcp" },
|
|
})
|
|
.returning();
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "send_email",
|
|
toolName: "send_email",
|
|
riskLevel: "write",
|
|
versionHash: randomUUID(),
|
|
schemaHash: randomUUID(),
|
|
})
|
|
.returning();
|
|
const [invocation] = await db
|
|
.insert(toolInvocations)
|
|
.values({
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "send_email",
|
|
argumentsHash: "abc123",
|
|
argumentsSummary: {
|
|
summary: '{"to":"redacted"}',
|
|
sha256: "abc123",
|
|
sizeBytes: 18,
|
|
},
|
|
policyDecision: "require_approval",
|
|
approvalState: "pending",
|
|
status: "awaiting_approval",
|
|
})
|
|
.returning();
|
|
const [interaction] = await db
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: company.id,
|
|
issueId: issue.id,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee_on_accept",
|
|
title: "Approve tool action",
|
|
summary: "send_email requires approval.",
|
|
createdByAgentId: agent.id,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve send_email?",
|
|
acceptLabel: "Approve action",
|
|
rejectLabel: "Reject action",
|
|
target: {
|
|
type: "custom",
|
|
key: "tool-action:test",
|
|
revisionId: "abc123",
|
|
label: "send_email",
|
|
},
|
|
},
|
|
})
|
|
.returning();
|
|
const [actionRequest] = await db
|
|
.insert(toolActionRequests)
|
|
.values({
|
|
companyId: company.id,
|
|
invocationId: invocation.id,
|
|
issueId: issue.id,
|
|
interactionId: interaction.id,
|
|
status: "pending",
|
|
canonicalArgumentsHash: "abc123",
|
|
canonicalArgumentsSummary: {
|
|
summary: '{"to":"redacted"}',
|
|
sha256: "abc123",
|
|
sizeBytes: 18,
|
|
},
|
|
previewMarkdown: "Tool: `send_email`",
|
|
requestedByAgentId: agent.id,
|
|
})
|
|
.returning();
|
|
const [auditEvent] = await db
|
|
.insert(toolCallEvents)
|
|
.values({
|
|
companyId: company.id,
|
|
eventType: "approval_requested",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
invocationId: invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
toolName: "send_email",
|
|
decision: "require_approval",
|
|
outcome: "pending",
|
|
reasonCode: "requires_approval_policy",
|
|
requestHash: "abc123",
|
|
requestSummary: {
|
|
summary: '{"to":"redacted"}',
|
|
sha256: "abc123",
|
|
sizeBytes: 18,
|
|
},
|
|
metadata: { interactionId: interaction.id },
|
|
})
|
|
.returning();
|
|
|
|
const lookup = await createTestToolAccessService(db).getRunDecisionLookup(
|
|
company.id,
|
|
run.id,
|
|
);
|
|
|
|
expect(lookup).toMatchObject({
|
|
runId: run.id,
|
|
decisions: [
|
|
{
|
|
invocation: expect.objectContaining({
|
|
id: invocation.id,
|
|
runId: run.id,
|
|
toolName: "send_email",
|
|
}),
|
|
actionRequest: expect.objectContaining({
|
|
id: actionRequest.id,
|
|
status: "pending",
|
|
}),
|
|
latestAuditEvent: expect.objectContaining({
|
|
id: auditEvent.id,
|
|
actionRequestId: actionRequest.id,
|
|
}),
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
pendingAction: expect.objectContaining({
|
|
actionRequestId: actionRequest.id,
|
|
interactionId: interaction.id,
|
|
previewMarkdown: "Tool: `send_email`",
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("enriches connection activity with issue and approval resolver context", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "GitHub"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "GitHub",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://github.example/mcp" },
|
|
transportConfig: { url: "https://github.example/mcp" },
|
|
})
|
|
.returning();
|
|
const [issue] = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId: company.id,
|
|
title: "Fix app connection copy",
|
|
status: "in_progress",
|
|
identifier: "PAP-10912",
|
|
assigneeAgentId: agent.id,
|
|
})
|
|
.returning();
|
|
const [run] = await db
|
|
.insert(heartbeatRuns)
|
|
.values({
|
|
companyId: company.id,
|
|
agentId: agent.id,
|
|
invocationSource: "assignment",
|
|
status: "running",
|
|
startedAt: new Date("2026-06-12T10:00:00Z"),
|
|
})
|
|
.returning();
|
|
const [catalogEntry] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
name: "mark_done",
|
|
toolName: "mark_done",
|
|
title: "Mark done",
|
|
riskLevel: "write",
|
|
isWrite: true,
|
|
status: "active",
|
|
versionHash: "v1",
|
|
schemaHash: "s1",
|
|
})
|
|
.returning();
|
|
const [invocation] = await db
|
|
.insert(toolInvocations)
|
|
.values({
|
|
companyId: company.id,
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
toolName: "Mark done",
|
|
policyDecision: "require_approval",
|
|
approvalState: "approved",
|
|
status: "completed",
|
|
})
|
|
.returning();
|
|
await db.insert(authUsers).values({
|
|
id: "board-user",
|
|
name: "Dotta",
|
|
email: "dotta@example.com",
|
|
emailVerified: true,
|
|
createdAt: new Date("2026-06-12T09:00:00Z"),
|
|
updatedAt: new Date("2026-06-12T09:00:00Z"),
|
|
});
|
|
const [actionRequest] = await db
|
|
.insert(toolActionRequests)
|
|
.values({
|
|
companyId: company.id,
|
|
invocationId: invocation.id,
|
|
issueId: issue.id,
|
|
status: "approved",
|
|
canonicalArgumentsHash: "abc123",
|
|
canonicalArgumentsSummary: {
|
|
summary: "{}",
|
|
sha256: "abc123",
|
|
sizeBytes: 2,
|
|
},
|
|
requestedByAgentId: agent.id,
|
|
resolvedByUserId: "board-user",
|
|
resolvedAt: new Date("2026-06-12T10:05:00Z"),
|
|
})
|
|
.returning();
|
|
await db.insert(toolCallEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
invocationId: invocation.id,
|
|
toolName: "Get value",
|
|
decision: "allow",
|
|
outcome: "success",
|
|
createdAt: new Date("2026-06-12T10:04:00Z"),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "approval_resolved",
|
|
actorType: "agent",
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
runId: run.id,
|
|
issueId: issue.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
catalogEntryId: catalogEntry.id,
|
|
invocationId: invocation.id,
|
|
actionRequestId: actionRequest.id,
|
|
toolName: "Mark done",
|
|
decision: "require_approval",
|
|
outcome: "success",
|
|
createdAt: new Date("2026-06-12T10:06:00Z"),
|
|
},
|
|
]);
|
|
|
|
const activity = await service.listConnectionActivity(
|
|
connection.id,
|
|
company.id,
|
|
10,
|
|
);
|
|
|
|
expect(activity.events.map((event) => event.eventType)).toEqual([
|
|
"approval_resolved",
|
|
"call_completed",
|
|
]);
|
|
expect(activity.issues[issue.id]).toEqual({
|
|
identifier: "PAP-10912",
|
|
title: "Fix app connection copy",
|
|
});
|
|
expect(activity.actionRequests[actionRequest.id]).toEqual({
|
|
status: "approved",
|
|
resolverDisplayName: "Dotta",
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: "board-user",
|
|
});
|
|
});
|
|
|
|
it("surfaces connection lifecycle events on the activity timeline", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
})
|
|
.returning();
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Google Sheets"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Google Sheets (stdio smoke)",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://sheets.example/mcp" },
|
|
transportConfig: { url: "https://sheets.example/mcp" },
|
|
})
|
|
.returning();
|
|
await db.insert(authUsers).values({
|
|
id: "lifecycle-user",
|
|
name: "Dotta",
|
|
email: "dotta@example.com",
|
|
emailVerified: true,
|
|
createdAt: new Date("2026-06-12T09:00:00Z"),
|
|
updatedAt: new Date("2026-06-12T09:00:00Z"),
|
|
});
|
|
|
|
await db.insert(activityLog).values([
|
|
{
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "lifecycle-user",
|
|
action: "tool_app.connected",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: { galleryKey: "google-sheets" },
|
|
createdAt: new Date("2026-06-12T10:00:00Z"),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "lifecycle-user",
|
|
action: "tool_connection.updated",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: { lifecycle: "paused", enabled: false },
|
|
createdAt: new Date("2026-06-12T10:01:00Z"),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "lifecycle-user",
|
|
action: "tool_connection.updated",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: {
|
|
lifecycle: "allowlist_changed",
|
|
added: 2,
|
|
removed: 0,
|
|
total: 2,
|
|
},
|
|
createdAt: new Date("2026-06-12T10:02:00Z"),
|
|
},
|
|
{
|
|
// A plain settings update (no lifecycle tag) must stay out of the feed.
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "lifecycle-user",
|
|
action: "tool_connection.updated",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: { status: "active", enabled: true },
|
|
createdAt: new Date("2026-06-12T10:03:00Z"),
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
actorType: "user",
|
|
actorId: "board",
|
|
action: "tool_connection.archived",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: { transport: "mcp_remote" },
|
|
createdAt: new Date("2026-06-12T10:04:00Z"),
|
|
},
|
|
]);
|
|
|
|
await db.insert(toolAccessAuditEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
actorType: "system",
|
|
action: "tool_connection.catalog_refresh",
|
|
outcome: "success",
|
|
details: { discoveredCount: 5, quarantinedCount: 3 },
|
|
createdAt: new Date("2026-06-12T10:05:00Z"),
|
|
},
|
|
{
|
|
// A refresh that quarantined nothing should not appear.
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
actorType: "system",
|
|
action: "tool_connection.catalog_refresh",
|
|
outcome: "success",
|
|
details: { discoveredCount: 5, quarantinedCount: 0 },
|
|
createdAt: new Date("2026-06-12T09:59:00Z"),
|
|
},
|
|
]);
|
|
|
|
const activity = await service.listConnectionActivity(
|
|
connection.id,
|
|
company.id,
|
|
20,
|
|
);
|
|
|
|
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual([
|
|
"actions_quarantined",
|
|
"disconnected",
|
|
"allowlist_changed",
|
|
"app_paused",
|
|
"app_connected",
|
|
]);
|
|
|
|
const byType = Object.fromEntries(
|
|
activity.lifecycleEvents.map((event) => [event.type, event]),
|
|
);
|
|
expect(byType.app_connected?.actorDisplayName).toBe("Dotta");
|
|
expect(byType.app_paused?.actorDisplayName).toBe("Dotta");
|
|
expect(byType.allowlist_changed?.details).toMatchObject({
|
|
added: 2,
|
|
removed: 0,
|
|
});
|
|
expect(byType.disconnected?.actorDisplayName).toBe("The board");
|
|
expect(byType.actions_quarantined?.details).toMatchObject({ count: 3 });
|
|
});
|
|
|
|
it("rejects runtime controls for non-local runtime kinds", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Remote app"))
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Remote runtime",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { url: "https://fixture.example/mcp" },
|
|
transportConfig: { url: "https://fixture.example/mcp" },
|
|
})
|
|
.returning();
|
|
const [slot] = await db
|
|
.insert(toolRuntimeSlots)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
slotKey: `${connection.id}:remote`,
|
|
ownerScopeType: "connection",
|
|
ownerScopeId: connection.id,
|
|
runtimeKind: "mcp_remote",
|
|
status: "running",
|
|
reuseKey: connection.id,
|
|
provider: "paperclip",
|
|
providerRef: "remote:https://fixture.example/mcp",
|
|
healthStatus: "ok",
|
|
})
|
|
.returning();
|
|
|
|
await expect(
|
|
service.stopRuntimeSlot(company.id, slot.id, {
|
|
actorType: "user",
|
|
actorId: "board-user",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "runtime_control_unsupported",
|
|
runtimeKind: "mcp_remote",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("summarizes runtime health and flags stale slots plus degraded connections", async () => {
|
|
const company = await createCompany(db);
|
|
const generatedAt = new Date("2026-06-06T00:00:00.000Z");
|
|
const service = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: null,
|
|
now: () => generatedAt,
|
|
});
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
name: "Local stdio fixture",
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
})
|
|
.returning();
|
|
const [connection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Degraded local stdio",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
transportConfig: { templateId: "paperclip.echo-calculator-time" },
|
|
healthStatus: "missing_secret",
|
|
healthMessage: "A configured credential secret could not be resolved.",
|
|
})
|
|
.returning();
|
|
const staleAt = new Date(generatedAt.getTime() - 10 * 60 * 1000);
|
|
await db.insert(toolRuntimeSlots).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
connectionId: connection.id,
|
|
slotKey: `${connection.id}:paperclip.echo-calculator-time`,
|
|
ownerScopeType: "connection",
|
|
ownerScopeId: connection.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "running",
|
|
reuseKey: connection.id,
|
|
provider: "paperclip",
|
|
providerRef: "local-stdio:test-host:slot",
|
|
commandTemplateKey: "paperclip.echo-calculator-time",
|
|
healthStatus: "ok",
|
|
startedAt: staleAt,
|
|
lastUsedAt: staleAt,
|
|
updatedAt: staleAt,
|
|
});
|
|
await db.insert(toolAccessAuditEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
action: "runtime_deferred",
|
|
outcome: "failure",
|
|
reasonCode: "runtime_host_capacity_exhausted",
|
|
details: { durationMs: 250 },
|
|
createdAt: generatedAt,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
action: "runtime_restart_suppressed",
|
|
outcome: "failure",
|
|
reasonCode: "runtime_restart_suppressed",
|
|
details: {},
|
|
createdAt: generatedAt,
|
|
},
|
|
]);
|
|
await db.insert(toolCallEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_failed",
|
|
outcome: "timeout",
|
|
toolName: "mcp-stdio-fixture:increment_counter",
|
|
createdAt: generatedAt,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
outcome: "success",
|
|
toolName: "mcp-stdio-fixture:runtime_status",
|
|
createdAt: generatedAt,
|
|
},
|
|
]);
|
|
|
|
const health = await service.getRuntimeHealth(company.id);
|
|
|
|
expect(health.status).toBe("critical");
|
|
expect(health.supportMatrix.localStdio.supported).toBe(false);
|
|
expect(health.metrics).toMatchObject({
|
|
activeSlots: 1,
|
|
runningSlots: 1,
|
|
stuckRunningSlots: 1,
|
|
capacityDeferralsLastHour: 1,
|
|
restartSuppressionsLastHour: 1,
|
|
toolCallsLastHour: 2,
|
|
toolTimeoutsLastHour: 1,
|
|
timeoutRateLastHour: 50,
|
|
degradedConnections: 1,
|
|
localStdioConnections: 1,
|
|
auditWriteFailuresLastHour: 0,
|
|
});
|
|
expect(health.alerts.map((alert) => alert.name)).toEqual(
|
|
expect.arrayContaining([
|
|
"mcp_runtime_stuck_running_slot",
|
|
"mcp_runtime_restart_storm",
|
|
"mcp_runtime_connection_health_degraded",
|
|
]),
|
|
);
|
|
expect(
|
|
health.recommendations.find(
|
|
(alert) => alert.name === "mcp_runtime_audit_write_failures",
|
|
),
|
|
).toMatchObject({
|
|
status: "ok",
|
|
observed: "0 audit write failure(s) in 1 hour.",
|
|
});
|
|
});
|
|
|
|
it("fires runtime health from the durable audit-write failure counter", async () => {
|
|
const company = await createCompany(db);
|
|
const generatedAt = new Date("2026-06-06T00:00:00.000Z");
|
|
const service = createTestToolAccessService(db, { now: () => generatedAt });
|
|
|
|
await db.insert(toolRuntimeMetricCounters).values({
|
|
companyId: company.id,
|
|
metric: "audit_write_failed",
|
|
bucketStartAt: new Date(generatedAt.getTime() - 5 * 60 * 1000),
|
|
count: 2,
|
|
createdAt: generatedAt,
|
|
updatedAt: generatedAt,
|
|
});
|
|
|
|
const health = await service.getRuntimeHealth(company.id);
|
|
|
|
expect(health.metrics.auditWriteFailuresLastHour).toBe(2);
|
|
expect(
|
|
health.alerts.find(
|
|
(alert) => alert.name === "mcp_runtime_audit_write_failures",
|
|
),
|
|
).toMatchObject({
|
|
severity: "critical",
|
|
status: "firing",
|
|
observed: "2 audit write failure(s) in 1 hour.",
|
|
});
|
|
});
|
|
|
|
it("does not degrade runtime health for draft or not-enabled setup connections", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db, {
|
|
now: () => new Date("2026-06-06T00:00:00.000Z"),
|
|
});
|
|
const [application] = await db
|
|
.insert(toolApplications)
|
|
.values(applicationRow(company, "Setup apps"))
|
|
.returning();
|
|
await db.insert(toolConnections).values([
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Imported draft",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
enabled: false,
|
|
config: { url: "https://draft.example/mcp" },
|
|
transportConfig: { url: "https://draft.example/mcp" },
|
|
healthStatus: "missing_secret",
|
|
healthMessage: "Needs setup before first use.",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "OAuth connected, not enabled",
|
|
uid: `test/${randomUUID()}`,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: false,
|
|
config: { url: "https://not-enabled.example/mcp" },
|
|
transportConfig: { url: "https://not-enabled.example/mcp" },
|
|
healthStatus: "missing_secret",
|
|
healthMessage: "Catalog access has not been enabled.",
|
|
},
|
|
]);
|
|
|
|
const health = await service.getRuntimeHealth(company.id);
|
|
|
|
expect(health.status).toBe("ok");
|
|
expect(health.metrics).toMatchObject({
|
|
activeConnections: 0,
|
|
disabledConnections: 0,
|
|
degradedConnections: 0,
|
|
});
|
|
expect(health.alerts.map((alert) => alert.name)).not.toContain(
|
|
"mcp_runtime_connection_health_degraded",
|
|
);
|
|
expect(
|
|
health.recommendations.find(
|
|
(alert) => alert.name === "mcp_runtime_connection_health_degraded",
|
|
),
|
|
).toMatchObject({
|
|
status: "ok",
|
|
observed: "0 degraded connection(s), 0 disabled connection(s).",
|
|
});
|
|
});
|
|
|
|
it("rejects enabled local stdio connections in public hosted mode without a trusted runtime host", async () => {
|
|
const company = await createCompany(db);
|
|
const hostedService = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: null,
|
|
});
|
|
|
|
await expect(
|
|
hostedService.createConnection(company.id, {
|
|
name: "Hosted local stdio",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
message: expect.stringContaining("cannot be enabled"),
|
|
});
|
|
|
|
const trustedService = createTestToolAccessService(db, {
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "public",
|
|
trustedLocalStdioRuntimeHost: "trusted-worker-1",
|
|
});
|
|
await expect(
|
|
trustedService.createConnection(company.id, {
|
|
name: "Trusted hosted local stdio",
|
|
transport: "local_stdio",
|
|
config: { templateId: "paperclip.echo-calculator-time" },
|
|
enabled: true,
|
|
status: "active",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
transport: "local_stdio",
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("previews mcp.json imports as draft managed connection records without carrying raw header values", async () => {
|
|
const company = await createCompany(db);
|
|
const preview = await createTestToolAccessService(db).previewMcpJsonImport({
|
|
mcpJson: {
|
|
mcpServers: {
|
|
github: {
|
|
url: "https://mcp.example/github",
|
|
headers: { Authorization: "Bearer should-not-be-stored" },
|
|
},
|
|
local: {
|
|
command: "npx",
|
|
args: ["-y", "@example/local-mcp"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(company.id).toBeTruthy();
|
|
expect(JSON.stringify(preview)).not.toContain("should-not-be-stored");
|
|
expect(preview.drafts).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "github",
|
|
transport: "mcp_remote",
|
|
status: "draft",
|
|
config: { url: "https://mcp.example/github" },
|
|
warnings: [expect.stringContaining("Paperclip secret")],
|
|
}),
|
|
expect.objectContaining({
|
|
name: "local",
|
|
transport: "local_stdio",
|
|
status: "draft",
|
|
config: {
|
|
importedCommand: "npx",
|
|
importedArgs: ["-y", "@example/local-mcp"],
|
|
},
|
|
warnings: [expect.stringContaining("approved Paperclip template")],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("fails closed when credential secrets cannot be resolved and writes value-free audit", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Secret-backed remote",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialRefs: [
|
|
{
|
|
name: "authorization",
|
|
secretId: randomUUID(),
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
|
|
await expect(
|
|
service.checkHealth(connection.id, {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({ code: "secret_missing" }),
|
|
});
|
|
const [updatedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
const [audit] = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "tool_connection.health_check"));
|
|
|
|
expect(updatedConnection).toMatchObject({
|
|
healthStatus: "missing_secret",
|
|
healthMessage: "A configured credential secret could not be resolved.",
|
|
});
|
|
expect(audit).toMatchObject({
|
|
action: "tool_connection.health_check",
|
|
outcome: "failure",
|
|
reasonCode: "secret_missing",
|
|
details: { status: "missing_secret", transport: "mcp_remote" },
|
|
});
|
|
expect(JSON.stringify(audit)).not.toContain("Bearer ");
|
|
expect(JSON.stringify(audit)).not.toContain("Authorization");
|
|
});
|
|
|
|
it("sweeps enabled active connection health and records failing connections", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("revoked token"));
|
|
const [chatApplication] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationKey: `chat:slack:${randomUUID()}`,
|
|
name: "Slack chat",
|
|
type: "chat",
|
|
status: "active",
|
|
metadata: { sourceTemplateKey: "slack", purpose: "channel" },
|
|
})
|
|
.returning();
|
|
const [chatConnection] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
companyId: company.id,
|
|
applicationId: chatApplication!.id,
|
|
name: "Slack agent",
|
|
uid: `chat-slack-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
connectionPurpose: "channel",
|
|
ownership: "customer",
|
|
transport: "chat_sdk",
|
|
authKind: "api_key",
|
|
credentialPolicy: "shared",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { provider: "slack" },
|
|
transportConfig: {},
|
|
healthStatus: "ok",
|
|
healthMessage: "Slack webhook healthy.",
|
|
healthCheckedAt: new Date(0),
|
|
lastHealthAt: new Date(0),
|
|
})
|
|
.returning();
|
|
const [pluginApplication] = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `paperclip_plugin:fixture-${randomUUID()}`,
|
|
name: "Plugin placeholder",
|
|
type: "paperclip_plugin",
|
|
status: "active",
|
|
metadata: { source: "plugin_backfill" },
|
|
}).returning();
|
|
const [pluginConnection] = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: pluginApplication!.id,
|
|
name: "Plugin placeholder",
|
|
uid: `plugin-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
config: { type: "paperclip_plugin" },
|
|
transportConfig: { type: "paperclip_plugin" },
|
|
healthStatus: "ok",
|
|
healthCheckedAt: null,
|
|
}).returning();
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Swept remote",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
|
|
const sweep = await service.sweepConnectionHealth({ staleAfterMs: 0, limit: 1 });
|
|
const [updatedConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connection.id));
|
|
const [untouchedChatConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, chatConnection!.id));
|
|
|
|
const [untouchedPlugin] = await db.select().from(toolConnections)
|
|
.where(eq(toolConnections.id, pluginConnection!.id));
|
|
expect(untouchedPlugin).toMatchObject({ enabled: true, healthStatus: "ok", healthCheckedAt: null });
|
|
|
|
expect(sweep).toMatchObject({
|
|
checked: 1,
|
|
healthy: 0,
|
|
failed: 1,
|
|
failedConnectionIds: [connection.id],
|
|
});
|
|
expect(updatedConnection).toMatchObject({
|
|
healthStatus: "error",
|
|
healthMessage: "revoked token",
|
|
lastError: "revoked token",
|
|
});
|
|
expect(untouchedChatConnection).toMatchObject({
|
|
healthStatus: "ok",
|
|
healthMessage: "Slack webhook healthy.",
|
|
lastError: null,
|
|
healthCheckedAt: new Date(0),
|
|
lastHealthAt: new Date(0),
|
|
});
|
|
});
|
|
|
|
it("enriches listConnections with lastUsedAt from the most recent tool-call event", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
const used = await service.createConnection(company.id, {
|
|
name: "Used remote",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://used.example/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
const unused = await service.createConnection(company.id, {
|
|
name: "Unused remote",
|
|
transport: "mcp_remote",
|
|
config: { url: "https://unused.example/mcp" },
|
|
enabled: true,
|
|
status: "active",
|
|
});
|
|
|
|
const older = new Date("2026-06-01T00:00:00.000Z");
|
|
const newest = new Date("2026-06-09T12:30:00.000Z");
|
|
await db.insert(toolCallEvents).values([
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
connectionId: used.id,
|
|
toolName: "search_notes",
|
|
outcome: "success",
|
|
createdAt: older,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
eventType: "call_completed",
|
|
connectionId: used.id,
|
|
toolName: "search_notes",
|
|
outcome: "success",
|
|
createdAt: newest,
|
|
},
|
|
]);
|
|
|
|
const connections = await service.listConnections(company.id);
|
|
const usedRow = connections.find((connection) => connection.id === used.id);
|
|
const unusedRow = connections.find(
|
|
(connection) => connection.id === unused.id,
|
|
);
|
|
|
|
expect(new Date(usedRow!.lastUsedAt!).toISOString()).toBe(
|
|
newest.toISOString(),
|
|
);
|
|
expect(unusedRow!.lastUsedAt).toBeNull();
|
|
});
|
|
|
|
it("syncs installs without widening action access or calling the remote tool", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
const app = createRouteApp(
|
|
db,
|
|
undefined,
|
|
createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
}),
|
|
);
|
|
|
|
const put = await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [{ targetType: "agent", targetId: agent.id }] });
|
|
|
|
expect(put.status).toBe(200);
|
|
expect(put.body).toMatchObject({
|
|
connectionId: connection.id,
|
|
installs: [{ targetType: "agent", targetId: agent.id }],
|
|
});
|
|
|
|
const [install] = await db.select().from(toolConnectionInstalls);
|
|
expect(install).toMatchObject({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
targetId: agent.id,
|
|
});
|
|
const profile = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connection.id}`));
|
|
expect(profile).toHaveLength(1);
|
|
const binding = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.profileId, profile[0]!.id),
|
|
eq(toolProfileBindings.targetType, "agent"),
|
|
eq(toolProfileBindings.targetId, agent.id),
|
|
),
|
|
);
|
|
expect(binding).toHaveLength(1);
|
|
const events = await db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(eq(activityLog.action, "tool_connection.install_access_extended"));
|
|
expect(events).toHaveLength(1);
|
|
|
|
const effective = await createTestToolAccessService(
|
|
db,
|
|
).getEffectiveProfilesForAgent(company.id, agent.id);
|
|
expect(effective.installedConnections.map((item) => item.id)).toEqual([
|
|
connection.id,
|
|
]);
|
|
expect(
|
|
effective.allowedTools.some(
|
|
(tool) => tool.connectionId === connection.id,
|
|
),
|
|
).toBe(false);
|
|
|
|
const deniedCall = await request(app)
|
|
.post(`/api/tool-connections/${connection.id}/test-calls`)
|
|
.send({
|
|
agentId: agent.id,
|
|
toolName: "send_email",
|
|
parameters: { to: "a@example.com" },
|
|
})
|
|
.expect(200);
|
|
expect(deniedCall.body).toMatchObject({
|
|
decision: "off",
|
|
error: { reasonCode: "deny_default" },
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
|
|
const get = await request(app).get(
|
|
`/api/tool-connections/${connection.id}`,
|
|
);
|
|
expect(get.status).toBe(200);
|
|
expect(get.body.installs).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("removes the install-derived binding when an agent is uninstalled, and keeps an operator-authored one", async () => {
|
|
const company = await createCompany(db);
|
|
const agent = await createAgent(db, company.id);
|
|
const other = await createAgent(db, company.id);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const app = createRouteApp(
|
|
db,
|
|
undefined,
|
|
createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
}),
|
|
);
|
|
|
|
await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [{ targetType: "agent", targetId: agent.id }] })
|
|
.expect(200);
|
|
|
|
const [profile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.profileKey, `app:${connection.id}`));
|
|
expect(profile).toBeDefined();
|
|
|
|
const bindingsFor = async (targetId: string) =>
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.profileId, profile!.id),
|
|
eq(toolProfileBindings.targetType, "agent"),
|
|
eq(toolProfileBindings.targetId, targetId),
|
|
),
|
|
);
|
|
|
|
expect(await bindingsFor(agent.id)).toHaveLength(1);
|
|
|
|
// A binding the operator authored through the access model, not through an
|
|
// install. Uninstalling must not touch it.
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: company.id,
|
|
profileId: profile!.id,
|
|
targetType: "agent",
|
|
targetId: other.id,
|
|
priority: 100,
|
|
metadata: { source: "operator" },
|
|
});
|
|
|
|
await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [] })
|
|
.expect(200);
|
|
|
|
// The install row is gone, so the agent can no longer reach the connection.
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.connectionId, connection.id)),
|
|
).toHaveLength(0);
|
|
// The binding the install created is gone too, so the permission state cannot
|
|
// report an agent the operator already removed.
|
|
expect(await bindingsFor(agent.id)).toHaveLength(0);
|
|
// The operator-authored binding survives.
|
|
expect(await bindingsFor(other.id)).toHaveLength(1);
|
|
|
|
const effective = await createTestToolAccessService(
|
|
db,
|
|
).getEffectiveProfilesForAgent(company.id, agent.id);
|
|
expect(effective.installedConnections.map((item) => item.id)).not.toContain(
|
|
connection.id,
|
|
);
|
|
});
|
|
|
|
it("limits connection configuration to the creator or a manager with role defaults", async () => {
|
|
const company = await createCompany(db);
|
|
const creator = boardSessionActor(
|
|
company.id,
|
|
"member",
|
|
`creator-${randomUUID()}`,
|
|
);
|
|
const otherMember = boardSessionActor(
|
|
company.id,
|
|
"member",
|
|
`member-${randomUUID()}`,
|
|
);
|
|
const admin = boardSessionActor(
|
|
company.id,
|
|
"admin",
|
|
`admin-${randomUUID()}`,
|
|
);
|
|
await grantBoardUser(db, company.id, creator.userId!, [], "member");
|
|
await grantBoardUser(db, company.id, otherMember.userId!, [], "member");
|
|
await grantBoardUser(db, company.id, admin.userId!, [], "admin");
|
|
const connection = await createTestToolAccessService(db).createConnection(
|
|
company.id,
|
|
{
|
|
name: "Creator-owned connection",
|
|
transport: "mcp_remote",
|
|
config: { url: PUBLIC_MCP_FIXTURE_URL },
|
|
},
|
|
{ actorType: "user", actorId: creator.userId! },
|
|
);
|
|
|
|
const denied = await request(createRouteApp(db, otherMember))
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ name: "Member edit" });
|
|
expect(denied.status).toBe(403);
|
|
expect(denied.body.error).toContain(
|
|
"Missing permission: tools:manage_connections",
|
|
);
|
|
|
|
await request(createRouteApp(db, creator))
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ name: "Creator edit" })
|
|
.expect(200);
|
|
await request(createRouteApp(db, admin))
|
|
.patch(`/api/tool-connections/${connection.id}`)
|
|
.send({ name: "Admin edit" })
|
|
.expect(200);
|
|
|
|
const adminGrants = await db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.principalId, admin.userId!));
|
|
expect(adminGrants).toEqual([]);
|
|
});
|
|
|
|
it("keeps agent installs self-serve for members with connection access and audits changes", async () => {
|
|
const company = await createCompany(db);
|
|
const creator = boardSessionActor(
|
|
company.id,
|
|
"member",
|
|
`creator-${randomUUID()}`,
|
|
);
|
|
const member = boardSessionActor(
|
|
company.id,
|
|
"member",
|
|
`member-${randomUUID()}`,
|
|
);
|
|
await grantBoardUser(db, company.id, creator.userId!, [], "member");
|
|
await grantBoardUser(
|
|
db,
|
|
company.id,
|
|
member.userId!,
|
|
["agents:configure"],
|
|
"member",
|
|
);
|
|
const agent = await createAgent(db, company.id);
|
|
const connection = await createTestToolAccessService(db).createConnection(
|
|
company.id,
|
|
{
|
|
name: "Shared organization connection",
|
|
transport: "mcp_remote",
|
|
config: { url: PUBLIC_MCP_FIXTURE_URL },
|
|
},
|
|
{ actorType: "user", actorId: creator.userId! },
|
|
);
|
|
const app = createRouteApp(db, member);
|
|
|
|
await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [{ targetType: "agent", targetId: agent.id }] })
|
|
.expect(200);
|
|
await request(app)
|
|
.put(`/api/tool-connections/${connection.id}/installs`)
|
|
.send({ installs: [] })
|
|
.expect(200);
|
|
|
|
const audits = await db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(eq(toolAccessAuditEvents.action, "connection_installs.changed"));
|
|
expect(audits).toHaveLength(2);
|
|
expect(
|
|
audits.every(
|
|
(audit) =>
|
|
audit.actorType === "user" && audit.actorId === member.userId,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("classifyRisk", () => {
|
|
it("classifies Fireflies reads and mutations without changing action defaults", () => {
|
|
for (const name of ["fireflies_get_transcripts", "fireflies_get_transcript", "fireflies_get_summary"])
|
|
expect(classifyRisk({ name }, "fireflies")).toBe("read");
|
|
for (const name of ["fireflies_share_meeting", "fireflies_revoke_meeting_access", "fireflies_move_meeting", "fireflies_create_soundbite", "fireflies_update_meeting_title"])
|
|
expect(classifyRisk({ name, annotations: { readOnlyHint: true } }, "fireflies")).toBe("write");
|
|
expect(classifyRisk({ name: "fireflies_share_meeting", annotations: { destructiveHint: true } }, "fireflies")).toBe("destructive");
|
|
});
|
|
|
|
it("classifies Superagent mutations whose names read like reads as writes", () => {
|
|
for (const name of ["list_findings", "get_finding", "get_context_score", "list_agent_alerts"])
|
|
expect(classifyRisk({ name }, "superagent")).toBe("read");
|
|
for (const name of [
|
|
"triage_finding",
|
|
"restore_agent_builtin_rule",
|
|
"scan_package",
|
|
"discover_application_inventory",
|
|
"trigger_dependency_updates",
|
|
"test_telemetry_endpoint",
|
|
"create_repository_report",
|
|
"set_agent_builtin_rule_mode",
|
|
])
|
|
expect(classifyRisk({ name }, "superagent")).toBe("write");
|
|
for (const name of ["delete_finding", "delete_agent_rule", "revoke_agent_client"])
|
|
expect(classifyRisk({ name }, "superagent")).toBe("destructive");
|
|
// A provider write hint wins over a read-looking name; a read-only hint
|
|
// opts a tool outside list/get into reads.
|
|
expect(classifyRisk({ name: "get_finding", annotations: { readOnlyHint: false } }, "superagent")).toBe("write");
|
|
expect(classifyRisk({ name: "validate_agent_rule", annotations: { readOnlyHint: true } }, "superagent")).toBe("read");
|
|
// Without the provider rule, the generic classifier treats these as reads.
|
|
expect(classifyRisk({ name: "triage_finding" })).toBe("read");
|
|
});
|
|
|
|
it("classifies Ditto translate, link and unlink tools as writes", () => {
|
|
for (const name of [
|
|
"get_styleguide_rules",
|
|
"suggest_edit",
|
|
"search_ditto_text",
|
|
"list_projects",
|
|
"list_variants",
|
|
"get_workspace_tags",
|
|
])
|
|
expect(classifyRisk({ name }, "ditto")).toBe("read");
|
|
for (const name of [
|
|
"translate_text_items",
|
|
"translate_library_components",
|
|
"link_figma_nodes_to_text_item",
|
|
"link_text_items_to_component",
|
|
"unlink_text_items_from_component",
|
|
"publish_library_component",
|
|
"create_text_items",
|
|
"update_styleguide_rules",
|
|
])
|
|
expect(classifyRisk({ name }, "ditto")).toBe("write");
|
|
for (const name of ["delete_text_items", "delete_styleguide_rules", "delete_variants"])
|
|
expect(classifyRisk({ name }, "ditto")).toBe("destructive");
|
|
expect(classifyRisk({ name: "list_projects", annotations: { readOnlyHint: false } }, "ditto")).toBe("write");
|
|
// Without the provider rule, the generic classifier treats these as reads.
|
|
expect(classifyRisk({ name: "translate_text_items" })).toBe("read");
|
|
expect(classifyRisk({ name: "link_text_items_to_component" })).toBe("read");
|
|
});
|
|
|
|
it("classifies Enterpret run_graph_query as write despite readOnlyHint", () => {
|
|
expect(
|
|
classifyRisk(
|
|
{ name: "run_graph_query", annotations: { readOnlyHint: true } },
|
|
"enterpret",
|
|
),
|
|
).toBe("write");
|
|
expect(
|
|
classifyRisk(
|
|
{ name: "get_organization_details", annotations: { readOnlyHint: true } },
|
|
"enterpret",
|
|
),
|
|
).toBe("read");
|
|
});
|
|
|
|
const risk = (name: string, annotations?: Record<string, unknown>) =>
|
|
classifyRisk({ name, annotations });
|
|
|
|
it("classifies unprefixed write verbs as write", () => {
|
|
expect(risk("create_widget")).toBe("write");
|
|
expect(risk("update_zap")).toBe("write");
|
|
expect(risk("send_message")).toBe("write");
|
|
expect(risk("set_value")).toBe("write");
|
|
});
|
|
|
|
it("classifies namespaced write verbs as write (PAP-10902)", () => {
|
|
// Real MCP servers return colon-namespaced names that the old leading-anchor
|
|
// regex fell through to "read", pre-enabling writes in the Connect wizard.
|
|
expect(risk("qa10864:create_widget")).toBe("write");
|
|
expect(risk("github:create_issue")).toBe("write");
|
|
expect(risk("notion:update_page")).toBe("write");
|
|
expect(risk("linear:create_issue")).toBe("write");
|
|
});
|
|
|
|
it("classifies camelCase write verbs as write", () => {
|
|
expect(risk("slack:postMessage")).toBe("write");
|
|
expect(risk("createIssue")).toBe("write");
|
|
});
|
|
|
|
it("classifies namespaced destructive verbs as destructive", () => {
|
|
expect(risk("delete_widget")).toBe("destructive");
|
|
expect(risk("github:delete_repo")).toBe("destructive");
|
|
expect(risk("notion:remove_page")).toBe("destructive");
|
|
expect(risk("cms:unpublish_post")).toBe("destructive");
|
|
});
|
|
|
|
it("classifies read verbs and noise as read", () => {
|
|
expect(risk("search_notes")).toBe("read");
|
|
expect(risk("github:list_issues")).toBe("read");
|
|
expect(risk("getUser")).toBe("read");
|
|
expect(risk("echo")).toBe("read");
|
|
// Verbs embedded mid-word must not trigger (no segment boundary).
|
|
expect(risk("settings")).toBe("read");
|
|
expect(risk("dataset_export")).toBe("read");
|
|
});
|
|
|
|
it("honours explicit annotation hints over name heuristics", () => {
|
|
expect(risk("list_items", { destructiveHint: true })).toBe("destructive");
|
|
expect(risk("list_items", { writeHint: true })).toBe("write");
|
|
expect(risk("list_items", { readOnlyHint: false })).toBe("write");
|
|
});
|
|
|
|
it("classifies the reviewed Notion MCP catalog with provider-scoped defaults", () => {
|
|
const notionRisk = (name: string, annotations?: Record<string, unknown>) =>
|
|
classifyRisk({ name, annotations }, "notion");
|
|
const readTools = [
|
|
"notion-search",
|
|
"notion-fetch",
|
|
"notion-query-data-sources",
|
|
"notion-query-database-view",
|
|
"notion-query-meeting-notes",
|
|
"notion-get-comments",
|
|
"notion-get-teams",
|
|
"notion-get-users",
|
|
"notion-get-async-task",
|
|
];
|
|
const writeTools = [
|
|
"notion-create-pages",
|
|
"notion-update-page",
|
|
"notion-convert-page-to-skill",
|
|
"notion-move-pages",
|
|
"notion-duplicate-page",
|
|
"notion-create-database",
|
|
"notion-create-folder",
|
|
"notion-update-data-source",
|
|
"notion-create-view",
|
|
"notion-update-view",
|
|
"notion-create-comment",
|
|
];
|
|
|
|
for (const toolName of readTools) expect(notionRisk(toolName)).toBe("read");
|
|
for (const toolName of writeTools) {
|
|
expect(notionRisk(toolName, { readOnlyHint: true })).toBe("write");
|
|
}
|
|
expect(notionRisk("notion-delete-page")).toBe("destructive");
|
|
expect(classifyRisk({ name: "move_pages" })).toBe("read");
|
|
});
|
|
|
|
it("uses conservative PostHog defaults for unknown and nested-execution tools", () => {
|
|
expect(
|
|
classifyRisk(
|
|
{ name: "query_insight", annotations: { readOnlyHint: true } },
|
|
"posthog",
|
|
),
|
|
).toBe("read");
|
|
expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write");
|
|
expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive");
|
|
});
|
|
|
|
it("keeps Shopify checkout completion and cancellation behind destructive-action approval", () => {
|
|
expect(classifyRisk({ name: "cancel_cart" }, "shopify")).toBe(
|
|
"destructive",
|
|
);
|
|
expect(classifyRisk({ name: "cancel_checkout" }, "shopify")).toBe(
|
|
"destructive",
|
|
);
|
|
expect(classifyRisk({ name: "complete_checkout" }, "shopify")).toBe(
|
|
"destructive",
|
|
);
|
|
expect(classifyRisk({ name: "create_cart" }, "shopify")).toBe("write");
|
|
});
|
|
});
|
|
|
|
describe("projectedConnectionHeaders", () => {
|
|
it.each([undefined, "managed", "mcp-key"])(
|
|
"adds Actions to the default GitHub toolsets for method %s without saved configuration",
|
|
(connectionMethodKey) => {
|
|
const connection = {
|
|
transport: "mcp_remote",
|
|
config: { sourceTemplateKey: "github", connectionMethodKey },
|
|
} as typeof toolConnections.$inferSelect;
|
|
expect(projectedConnectionHeaders(connection)).toEqual({
|
|
"X-MCP-Toolsets": "default,actions",
|
|
});
|
|
expect(connection.config).not.toHaveProperty("headers");
|
|
},
|
|
);
|
|
|
|
it("recognizes GitHub in legacy transport configuration", () => {
|
|
const connection = {
|
|
transport: "mcp_remote",
|
|
config: {},
|
|
transportConfig: { sourceTemplateKey: "github" },
|
|
} as typeof toolConnections.$inferSelect;
|
|
expect(projectedConnectionHeaders(connection)).toEqual({
|
|
"X-MCP-Toolsets": "default,actions",
|
|
});
|
|
});
|
|
|
|
it("keeps unrelated MCP connections unchanged", () => {
|
|
for (const connection of [
|
|
{ transport: "mcp_remote", config: { url: "https://fixture.example/mcp" } },
|
|
{ transport: "mcp_remote", config: { sourceTemplateKey: "notion" } },
|
|
]) {
|
|
expect(projectedConnectionHeaders(connection as typeof toolConnections.$inferSelect)).toEqual({});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("normalizeConnectionMethodConfig", () => {
|
|
const posthog = getConnectableAppDefinition("posthog")!;
|
|
const apiKeyMethod = posthog.methods.find(
|
|
(method) => method.key === "mcp-api-key",
|
|
)!;
|
|
const clickhouseMethod =
|
|
getConnectableAppDefinition("clickhouse")!.methods[0]!;
|
|
const shopifyMethods = getConnectableAppDefinition("shopify")!.methods;
|
|
const shopifyMethod = shopifyMethods.find(
|
|
(method) => method.key === "storefront-mcp",
|
|
)!;
|
|
const shopifyUcpMethod = shopifyMethods.find(
|
|
(method) => method.key === "ucp-commerce",
|
|
)!;
|
|
|
|
it("sends Telem settings as headers and leaves unset settings out", () => {
|
|
const telemMethod = getConnectableAppDefinition("telem")!.methods[0]!;
|
|
expect(normalizeConnectionMethodConfig(telemMethod, {})).toEqual({
|
|
values: {},
|
|
url: "https://mcp.telem.ai/mcp",
|
|
});
|
|
expect(
|
|
normalizeConnectionMethodConfig(telemMethod, {
|
|
autoRouting: "accuracy",
|
|
tier: "extended",
|
|
providersInclude: "brave, exa\nbrave",
|
|
providersExclude: "serpapi",
|
|
}),
|
|
).toEqual({
|
|
values: {
|
|
autoRouting: "accuracy",
|
|
tier: "extended",
|
|
providersInclude: "brave,exa",
|
|
providersExclude: "serpapi",
|
|
},
|
|
url: "https://mcp.telem.ai/mcp",
|
|
headers: {
|
|
"X-Telem-Auto-Routing": "accuracy",
|
|
"X-Telem-Tier": "extended",
|
|
"X-Telem-Providers-Include": "brave,exa",
|
|
"X-Telem-Providers-Exclude": "serpapi",
|
|
},
|
|
});
|
|
expect(
|
|
normalizeConnectionMethodConfig(telemMethod, { autoRouting: "off" }),
|
|
).toEqual({
|
|
values: { autoRouting: "off" },
|
|
url: "https://mcp.telem.ai/mcp",
|
|
headers: { "X-Telem-Auto-Routing": "off" },
|
|
});
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(telemMethod, { tier: "premium" }),
|
|
).toThrow("Tier has an invalid option");
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(telemMethod, {
|
|
providersInclude: "brave; drop",
|
|
}),
|
|
).toThrow("Providers to include has an invalid value");
|
|
});
|
|
|
|
it("builds a concrete Shopify endpoint from the validated store domain", () => {
|
|
expect(
|
|
normalizeConnectionMethodConfig(shopifyMethod, {
|
|
storeDomain: "paperclip-demo.myshopify.com",
|
|
}),
|
|
).toEqual({
|
|
values: { storeDomain: "paperclip-demo.myshopify.com" },
|
|
url: "https://paperclip-demo.myshopify.com/api/mcp",
|
|
});
|
|
});
|
|
|
|
it("uses the broad PostHog catalog when optional advanced filters are untouched", () => {
|
|
expect(normalizeConnectionMethodConfig(apiKeyMethod, {})).toEqual({
|
|
values: {
|
|
readOnly: false,
|
|
mode: "tools",
|
|
},
|
|
url: "https://mcp.posthog.com/mcp?mode=tools",
|
|
});
|
|
});
|
|
|
|
it("normalizes and projects PostHog scope without accepting arbitrary config", () => {
|
|
expect(
|
|
normalizeConnectionMethodConfig(apiKeyMethod, {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights, error_tracking\ninsights",
|
|
tools: "query_insight",
|
|
mode: "tools",
|
|
}),
|
|
).toEqual({
|
|
values: {
|
|
projectId: "12345",
|
|
readOnly: true,
|
|
features: "insights,error_tracking",
|
|
tools: "query_insight",
|
|
mode: "tools",
|
|
},
|
|
url: "https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools",
|
|
headers: { "x-posthog-project-id": "12345" },
|
|
});
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(apiKeyMethod, {
|
|
projectId: "not-a-project",
|
|
features: "insights",
|
|
}),
|
|
).toThrow("Pin to project ID has an invalid value");
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(apiKeyMethod, {
|
|
projectId: "12345",
|
|
features: "insights",
|
|
apiKey: "must-not-be-config",
|
|
}),
|
|
).toThrow("Unknown connection setting: apiKey");
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(clickhouseMethod, {
|
|
serviceId: "service-id\r\nX-Injected: yes",
|
|
}),
|
|
).toThrow("x-service-id");
|
|
});
|
|
|
|
it("builds a tenant-scoped Shopify endpoint from a validated store domain", () => {
|
|
expect(
|
|
normalizeConnectionMethodConfig(shopifyMethod, {
|
|
storeDomain: "rcvbsa-pz.myshopify.com",
|
|
}),
|
|
).toEqual({
|
|
values: { storeDomain: "rcvbsa-pz.myshopify.com" },
|
|
url: "https://rcvbsa-pz.myshopify.com/api/mcp",
|
|
});
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(shopifyMethod, {
|
|
storeDomain: "evil.example.com",
|
|
}),
|
|
).toThrow("Store domain has an invalid value");
|
|
expect(() =>
|
|
normalizeConnectionMethodConfig(shopifyMethod, {
|
|
storeDomain: "shop.myshopify.com@example.com",
|
|
}),
|
|
).toThrow("Store domain has an invalid value");
|
|
expect(
|
|
normalizeConnectionMethodConfig(shopifyUcpMethod, {
|
|
storeDomain: "rcvbsa-pz.myshopify.com",
|
|
}),
|
|
).toMatchObject({
|
|
url: "https://rcvbsa-pz.myshopify.com/api/ucp/mcp",
|
|
});
|
|
expect(
|
|
projectConnectionMethodToolArguments(shopifyUcpMethod, {
|
|
catalog: { query: "shirts" },
|
|
meta: {
|
|
caller: "kept",
|
|
"ucp-agent": { profile: "https://attacker.example/profile.json" },
|
|
},
|
|
}),
|
|
).toEqual({
|
|
catalog: { query: "shirts" },
|
|
meta: {
|
|
caller: "kept",
|
|
"ucp-agent": {
|
|
profile:
|
|
"https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json",
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
projectConnectionMethodToolInputSchema(shopifyUcpMethod, {
|
|
type: "object",
|
|
required: ["meta", "catalog"],
|
|
properties: {
|
|
meta: {
|
|
type: "object",
|
|
required: ["ucp-agent", "idempotency-key"],
|
|
properties: {
|
|
"ucp-agent": { type: "object" },
|
|
"idempotency-key": { type: "string" },
|
|
},
|
|
},
|
|
catalog: { type: "object" },
|
|
},
|
|
}),
|
|
).toEqual({
|
|
type: "object",
|
|
required: ["meta", "catalog"],
|
|
properties: {
|
|
meta: {
|
|
type: "object",
|
|
required: ["idempotency-key"],
|
|
properties: {
|
|
"idempotency-key": { type: "string" },
|
|
},
|
|
},
|
|
catalog: { type: "object" },
|
|
},
|
|
});
|
|
});
|
|
});
|