mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip lets people manage AI agents and govern their tools. > - Apps already supports provider-hosted MCP servers. > - Useful providers still need real setup and read proof before Browse exposure. > - Browser testing found a Bitly token-format failure and an OAuth callback that replaced an explicit empty agent selection. > - This PR qualifies four provider methods and fixes those shared OAuth paths. > - Providers that still need login or approval remain withheld from new Browse listings. ## Linked Issues or Issue Description Refs: #15621, the merged hosted MCP qualification batch. Searches found no duplicate Bitly or Context7 implementation PR. **What existing behavior does this improve?** Apps setup, provider MCP discovery, token exchange, and saved agent access through an OAuth callback. **Subsystem affected** Shared Apps definitions, the server tool-access service, and the UI setup/recovery route. **Current behavior** Bitly's callback can fail with `oauth_access_token_missing` when its token response is not JSON. Curated MCP callbacks can treat an explicitly saved empty install set as access for all agents. Context7 is hidden and Hugging Face lacks a curated anonymous method. Saved drafts for hidden providers cannot finish setup through their catalog wizard. **Proposed behavior** Request JSON token responses. Record explicit remote MCP install choices in the install transaction and preserve them at callback. Keep managed subject/default access when no choice was saved. Expose the qualified Bitly and Context7 methods, add anonymous Hugging Face Hub access, and record Resend qualification. Resume hidden providers only through an exact matching company-visible saved draft. **Reason and benefit** People can connect useful hosted tools through the existing Apps flow. An explicit zero-agent selection survives authorization. Public documentation lookups need no OAuth app, account, or key. **Breaking changes** Explicitly saved empty MCP install choices now remain empty. Existing managed defaults without a saved choice retain their behavior. No schema migration or API shape change. ## What Changed - Qualify Bitly OAuth, Context7 public MCP, Hugging Face public MCP, and Resend OAuth with real browser setup, useful UI reads, and reload checks. - Negotiate JSON OAuth token responses and preserve explicitly saved MCP agent reach with a transactional config marker. - Add durable provider overrides, permission reviews, dated text-only evidence, and official branding provenance. Remove all seven evidence images and their report references. - Restore Finish setup for hidden-provider drafts with company/application/provider checks; keep fresh setup withheld. Preserve success after the exact resumed draft becomes active, and restore valid completed URLs to that exact saved Permissions page; reject initially active, disabled, or archived resume targets. Align the Context7 route assertion with its new qualified availability. - Retain hidden ClickUp, Klaviyo, and monday.com research with exact login/approval gates. monday.com requires provider callback allowlisting. ## Verification - Connector service suite: 420 passed, including default/empty/agent/company reach and managed OAuth lifecycle coverage. - Shared catalog: 36 passed. Override tests: 3 passed. Brand checks: 101 identities passed. - Focused UI setup/recovery, Browse, and OAuth-helper suites: 306 passed. Actual hidden-draft resume/reload passed; a public Context7 draft seeded in the isolated app completed through the active-state refetch and persisted with zero agents after reload. The completed URL refresh and View connection open the exact saved Permissions page. Fresh hidden setup, normal initially active resume, disabled/archived status, and provider mismatch are rejected; a draft at the completion stage stays in setup. - Recursive typecheck and build passed again after rebase. The full local Vitest run began before rebase and was cancelled as superseded while still in the general-server phase; it is not a completed pass. Exact-head CI supplies broad coverage. The focused connector service suite passed against the final code. UI typecheck, build, and token gates passed again after the draft-recovery fix. - Targeted mock MCP Playwright journey: 1 passed (34.9s). It now verifies View connection reaches the exact saved Permissions route, both discovered actions, and reload persistence. This replaces the stale Browse expectation found by CI. - All CI gates passed on final commit `32bb4c6422411eee632486950e45e2735102d1b3`. The inherited-pause test timed out in the cleanup run and passed on one retry without code changes; the entire file also passed locally, 36/36. Its exact timing cause remains unresolved. A signoff timeout on the preceding production-code commit also passed on its single retry. Greptile reviewed the final commit at 5/5 with no open findings. - Actual UI reads: Bitly current-user read; Resend List Domains including a fresh setup after the final callback refinement; Context7 React docs; Hugging Face public docs search. No provider writes or real agent runtimes ran. - [Text-only qualification report](https://github.com/paperclipai/paperclip/blob/32bb4c6422411eee632486950e45e2735102d1b3/doc/connections/easy-mcp-batch-2-2026-10-09.md) include evidence limits and exact blockers. Account/profile/domain results are omitted. ## Risks - Bitly `mcp.all` and Resend `full_access` remain broad provider grants. Qualification kept writes Off and selected no agents. Read proof does not establish write behavior or refresh rotation. - The explicit-choice marker applies to new install saves; older unmarked flows keep their existing defaults. Its JSONB merge avoids overwriting concurrent config fields. - Public provider catalogs and rate limits can change. Unqualified providers remain withheld from new Browse entries; retained connections remain available for recovery. ## Model Used OpenAI Codex, based on GPT-6, plus OpenAI `gpt-6-luna` subagents for provider research, browser testing, and review. The exact primary serving variant and context-window size are not exposed by this runtime. Used reasoning, code execution, and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
20159 lines
688 KiB
TypeScript
20159 lines
688 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
import {
|
|
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,
|
|
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 { 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, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
|
|
|
|
const embeddedPostgresSupport = 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((req, _res, next) => {
|
|
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!,
|
|
};
|
|
}
|
|
|
|
describeEmbeddedPostgres("tool access service", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<
|
|
ReturnType<typeof startEmbeddedPostgresTestDatabase>
|
|
> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
const externalDatabaseUrl =
|
|
process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim();
|
|
if (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("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(tempDb!.connectionString, { maxConnections: 1 });
|
|
const revocationDb = createDb(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({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
status: "active",
|
|
isDefault: false,
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
status: "active",
|
|
isDefault: false,
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
status: "active",
|
|
isDefault: false,
|
|
});
|
|
|
|
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("need access to this connection");
|
|
});
|
|
|
|
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({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: "alice",
|
|
status: "active",
|
|
isDefault: false,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
|
const service = createTestToolAccessService(serviceDb);
|
|
const removalDb = createDb(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(tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const replacementDb = createDb(tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const cleanupDb = createDb(tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const replacementService = createTestToolAccessService(replacementDb);
|
|
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", []);
|
|
}
|
|
});
|
|
|
|
// The first replacement still owns the departing member lock, so this
|
|
// gives cleanup time to queue for that lock before the empty replacement
|
|
// queues for the grant lock.
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
let replacementSettled = false;
|
|
const replacement = replacementService
|
|
.replaceConnectionGrantMembers(connection.id, grant.id, [])
|
|
.then(
|
|
(value) => ({ value, error: null }),
|
|
(error: unknown) => ({ value: null, error }),
|
|
)
|
|
.finally(() => {
|
|
replacementSettled = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
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();
|
|
},
|
|
);
|
|
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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(["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([
|
|
{
|
|
name: "list_labels",
|
|
description: "Lists Gmail labels.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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" }),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("cancels an ask-first test request when approval signing is unavailable", async () => {
|
|
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 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("audits ask-first test calls with the real board actor and 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: `Ask first ${randomUUID()}`,
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
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);
|
|
|
|
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("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));
|
|
|
|
// 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(requests.map((row) => row.status).sort()).toEqual([
|
|
"approved",
|
|
"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("404s a single-id test-call status fetch for a non-test-origin action request", async () => {
|
|
const company = await createCompany(db);
|
|
const userId = `tool-tester-${randomUUID()}`;
|
|
await grantBoardUser(db, company.id, userId, ["tools:use"]);
|
|
const { connection } = await createRemoteToolFixture(db, company.id);
|
|
const gateway = createToolGatewayService(db, {
|
|
toolActionSigningSecret: "test-secret",
|
|
});
|
|
const app = createRouteApp(
|
|
db,
|
|
boardSessionActor(company.id, "operator", userId),
|
|
gateway,
|
|
);
|
|
|
|
await request(app)
|
|
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`)
|
|
.expect(404);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("audits blocked test calls with the real board actor and 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: `Block ${randomUUID()}`,
|
|
policyType: "block",
|
|
priority: 100,
|
|
selectors: { connectionId: connection.id },
|
|
});
|
|
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);
|
|
|
|
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 board-only 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, {
|
|
type: "board",
|
|
userId: "member-user",
|
|
userName: "Member User",
|
|
userEmail: null,
|
|
companyIds: [allowedCompany.id],
|
|
memberships: [
|
|
{
|
|
companyId: allowedCompany.id,
|
|
membershipRole: "owner",
|
|
status: "active",
|
|
},
|
|
],
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
});
|
|
|
|
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,
|
|
{
|
|
type: "board",
|
|
userId: "member-user",
|
|
userName: "Member User",
|
|
userEmail: null,
|
|
companyIds: [allowedCompany.id],
|
|
memberships: [
|
|
{
|
|
companyId: allowedCompany.id,
|
|
membershipRole: "owner",
|
|
status: "active",
|
|
},
|
|
],
|
|
isInstanceAdmin: true,
|
|
source: "session",
|
|
},
|
|
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",
|
|
"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", "vercel", "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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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"]);
|
|
|
|
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",
|
|
]);
|
|
});
|
|
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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 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: "firstprojectref12345",
|
|
readOnly: true,
|
|
},
|
|
},
|
|
{ actorType: "user", actorId: "board" },
|
|
);
|
|
|
|
const resumed = await service.connectGalleryApp(
|
|
company.id,
|
|
{
|
|
galleryKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
name,
|
|
configValues: {
|
|
projectRef: "secondprojectref1234",
|
|
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=secondprojectref1234&read_only=true&features=database",
|
|
sourceTemplateKey: "supabase",
|
|
connectionMethodKey: "mcp-oauth",
|
|
methodConfig: {
|
|
projectRef: "secondprojectref1234",
|
|
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: "firstprojectref12345",
|
|
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: "secondprojectref1234",
|
|
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: "secondprojectref1234",
|
|
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({
|
|
companyId: company.id,
|
|
name: "Google Sheets",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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}`,
|
|
});
|
|
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}`,
|
|
});
|
|
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(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({
|
|
companyId: company.id,
|
|
name: "GitHub authorization fixture",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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("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);
|
|
let transactionCount = 0;
|
|
const transactionSpy = vi
|
|
.spyOn(db, "transaction")
|
|
.mockImplementation((async (operation, config) => {
|
|
transactionCount += 1;
|
|
if (transactionCount === 2)
|
|
throw new Error("recommended defaults failed");
|
|
return originalTransaction(operation, 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("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(tempDb!.connectionString, {
|
|
maxConnections: 1,
|
|
});
|
|
const removalDb = createDb(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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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");
|
|
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: [],
|
|
});
|
|
});
|
|
|
|
// The limit of this fix, asserted so nobody reads `unrequestedScopes: []` as "no extra
|
|
// scopes were granted". This is exactly the Enterpret shape from PAP-18538: the token
|
|
// response omitted `scope` while the token really carried `mcp:write`, and only RFC 7662
|
|
// introspection revealed it. Paperclip does not introspect, so the extra scope is
|
|
// *unknown*, not absent. `requested_fallback` is the marker that says so; an operator who
|
|
// needs certainty has to check at the provider.
|
|
it("cannot see an over-grant that the provider hides by omitting scope", async () => {
|
|
const { recordedOAuth } = await connectSlackWithTokenScope(null, [
|
|
"channels:read",
|
|
]);
|
|
|
|
expect(recordedOAuth).toMatchObject({
|
|
scope: null,
|
|
// Not "provider": the scope list below is Paperclip's own request, so it carries no
|
|
// assurance about what the token can actually do.
|
|
scopeSource: "requested_fallback",
|
|
// Empty because nothing was asserted to compare against — NOT because the provider
|
|
// is known to have granted only what was asked.
|
|
unrequestedScopes: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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({
|
|
ok: true,
|
|
access_token: reconnecting
|
|
? "personal-access-token-2"
|
|
: "personal-access-token",
|
|
refresh_token: reconnecting
|
|
? "personal-refresh-token-2"
|
|
: "personal-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: expect.stringMatching(
|
|
/^Bearer personal-access-token(?:-2)?$/,
|
|
),
|
|
}),
|
|
);
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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("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({
|
|
ok: true,
|
|
access_token: "share-access-token",
|
|
refresh_token: "share-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
ok: true,
|
|
access_token: "refreshed-personal-access-token",
|
|
refresh_token: "rotated-personal-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
return mcpHttpResponse({
|
|
ok: true,
|
|
access_token: "personal-access-token",
|
|
refresh_token: "personal-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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({
|
|
ok: true,
|
|
access_token: "personal-access-token",
|
|
refresh_token: "personal-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
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("records the over-grant on the shared organization grant", async () => {
|
|
const lane = await connectSharedSlack("channels:read chat:write");
|
|
|
|
// The organization grant is created before OAuth has any credentials, so it used to be
|
|
// the one record with no provenance at all.
|
|
expect((await lane.readGrant()).providerTenant?.oauth).toMatchObject({
|
|
scopes: ["channels:read", "chat:write"],
|
|
scopeSource: "provider",
|
|
unrequestedScopes: ["chat:write"],
|
|
});
|
|
});
|
|
|
|
it("keeps the over-grant warning when a refresh asserts no scope", async () => {
|
|
const lane = await connectSharedSlack("channels:read 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({
|
|
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"],
|
|
});
|
|
}
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
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"],
|
|
});
|
|
}
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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 () => ({
|
|
ok: true,
|
|
access_token: "bound-access-token",
|
|
refresh_token: "bound-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer bound-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
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"],
|
|
});
|
|
}
|
|
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: "supabaseproject12345",
|
|
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=supabaseproject12345&read_only=true&features=database"
|
|
) {
|
|
expect(init?.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: "Bearer supabase-access-token",
|
|
}),
|
|
);
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [{ 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("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 () => ({
|
|
ok: true,
|
|
access_token: "old-access-token",
|
|
refresh_token: "refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} 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 () => ({
|
|
ok: true,
|
|
access_token: "new-access-token",
|
|
refresh_token: "new-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
}),
|
|
} as Response;
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ name: "search_messages", annotations: { readOnlyHint: true } },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback({
|
|
state,
|
|
code: "oauth-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
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({
|
|
ok: true,
|
|
access_token: "expired-access-token",
|
|
refresh_token: "single-use-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state,
|
|
code: "oauth-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
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({
|
|
ok: true,
|
|
access_token: "expired-access-token",
|
|
refresh_token: "submitted-refresh-token",
|
|
expires_in: 3600,
|
|
token_type: "Bearer",
|
|
});
|
|
}
|
|
if (href === "https://mcp.slack.com/mcp") {
|
|
return mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: { tools: [] },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
await service.completeOAuthCallback({
|
|
state,
|
|
code: "oauth-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
name: "search_messages",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${href}`);
|
|
});
|
|
|
|
await service.completeOAuthCallback({
|
|
state,
|
|
code: "oauth-code",
|
|
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
|
|
actor: { actorType: "user", actorId: "board" },
|
|
});
|
|
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({
|
|
companyId: company.id,
|
|
name: "Auto app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
name: "Attention review app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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([
|
|
{
|
|
name: "read_items",
|
|
description: "Read items.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
|
|
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"));
|
|
const runTransaction = db.transaction.bind(db);
|
|
vi.spyOn(db, "transaction")
|
|
.mockImplementationOnce(runTransaction)
|
|
.mockRejectedValueOnce(new Error("rollback unavailable"));
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
it("automatically gives same-named connections distinct names", async () => {
|
|
const company = await createCompany(db);
|
|
const service = createTestToolAccessService(db);
|
|
mockToolsList([
|
|
{
|
|
name: "read_items",
|
|
description: "Read items.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
|
|
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([
|
|
{
|
|
name: "read_items",
|
|
description: "Read items.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
|
|
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([
|
|
{
|
|
name: "read_items",
|
|
description: "Read items.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
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([
|
|
{
|
|
name: "read_items",
|
|
description: "Read items.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List Zapier actions.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{ 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([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List Zapier actions.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
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([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
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({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
result: {
|
|
tools: [
|
|
{
|
|
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("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([
|
|
{
|
|
name: "list_zaps",
|
|
description: "List",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
]);
|
|
|
|
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, {
|
|
type: "board",
|
|
userId: "member-user",
|
|
userName: "Member User",
|
|
userEmail: null,
|
|
companyIds: [allowedCompany.id],
|
|
memberships: [
|
|
{
|
|
companyId: allowedCompany.id,
|
|
membershipRole: "owner",
|
|
status: "active",
|
|
},
|
|
],
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
});
|
|
|
|
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, {
|
|
type: "board",
|
|
userId: "member-user",
|
|
userName: "Member User",
|
|
userEmail: null,
|
|
companyIds: [allowedCompany.id],
|
|
memberships: [
|
|
{
|
|
companyId: allowedCompany.id,
|
|
membershipRole: "owner",
|
|
status: "active",
|
|
},
|
|
],
|
|
isInstanceAdmin: false,
|
|
source: "session",
|
|
});
|
|
|
|
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({
|
|
companyId: company.id,
|
|
name: "Governed tools",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
name: "GitHub",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
name: "Google Sheets",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
name: "Remote app",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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({
|
|
companyId: company.id,
|
|
name: "Setup apps",
|
|
type: "mcp_http",
|
|
status: "active",
|
|
})
|
|
.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(
|
|
"connection creator or a connection manager",
|
|
);
|
|
|
|
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 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" },
|
|
},
|
|
});
|
|
});
|
|
});
|