Files
PaperClipAI/packages/shared/src/validators/tool-access.test.ts
T
Dotta d387cc0ff0 feat(connections): add managed external MCP connectors (#12346)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connection intents need secure provider implementations to complete
setup.
> - Some providers use managed OAuth or external credential brokers.
> - Those tokens must stay out of durable Paperclip state and fail
closed when refresh fails.
> - This pull request adds managed connector backends and the required
storage contract.
> - The benefit is safer provider setup with governed credential
lifecycles.

## Linked Issues or Issue Description

Refs #11965

This is stack 8 of 11. It depends on stack 7 and replaces another
reviewable part of #11965.

## What Changed

- Add managed Google Workspace and external connector backends.
- Add Vercel Connect support without storing provider bearer tokens.
- Add replay-safe migration 0232 and its generated snapshot.
- Fail closed and clear stale token bindings when organization OAuth
refresh needs reauthorization.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 194 tests passed.
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm build`
- `pnpm exec vitest run --project @paperclipai/server
server/src/services/remote-url-credentials.test.ts` (5 passed, including
URL userinfo vault extraction)

## Risks

- Broker metadata errors can block provider setup.
- OAuth refresh failure disables the shared organization connection
until reauthorization.
- Migration 0232 is generated, ordered after 0231, and safe to replay.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## 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 linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change 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
2026-08-29 12:08:34 -05:00

202 lines
7.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
connectionTokenRequestSchema,
connectToolAppSchema,
createToolMcpGatewayTokenSchema,
createToolConnectionSchema,
startConnectionAuthorizationSchema,
toolCredentialSecretRefSchema,
toolRedactedValueSummarySchema,
toolTransportConfigSchema,
} from "./tool-access.js";
describe("tool access validators", () => {
it("treats a gateway token owner note as optional", () => {
const parsed = createToolMcpGatewayTokenSchema.parse({
name: "cursor-client",
clientLabel: "cursor-client",
expiresAt: "2026-12-01T00:00:00.000Z",
});
expect(parsed.ownerNote).toBe("");
});
it("defaults connection token subjects to app", () => {
expect(connectionTokenRequestSchema.parse({})).toEqual({ subject: { type: "app" } });
});
it("accepts user subjects, grant selection, and authorization input", () => {
const request = connectionTokenRequestSchema.parse({
subject: { type: "user", userId: "user-123" },
grantId: "11111111-1111-4111-8111-111111111111",
});
expect(request.subject).toEqual({ type: "user", userId: "user-123" });
expect(startConnectionAuthorizationSchema.parse({ subjectUserId: "user-123", scopes: ["read"] })).toEqual({
subjectUserId: "user-123",
scopes: ["read"],
});
});
it("accepts multi-key credential annotations", () => {
const parsed = toolCredentialSecretRefSchema.parse({
secretId: "11111111-1111-4111-8111-111111111111",
configPath: "credentials.apiKey",
keyScope: "production",
expiresAt: "2027-01-01T00:00:00Z",
});
expect(parsed.keyScope).toBe("production");
});
it("rejects raw credential-looking fields in transport config", () => {
const parsed = toolTransportConfigSchema.safeParse({
url: "https://example.test/mcp",
headers: {
Authorization: "Bearer raw-token",
},
});
expect(parsed.success).toBe(false);
if (!parsed.success) {
expect(parsed.error.issues[0]?.message).toContain("credentialSecretRefs");
}
});
it("keeps app method configuration separate from secrets", () => {
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
connectionMethodKey: "mcp-api-key",
configValues: { projectId: "12345", readOnly: true, features: "insights" },
}).success).toBe(true);
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
configValues: { projectId: "12345", apiKey: "phx_raw" },
}).success).toBe(false);
});
it("accepts only UUID connection request links during app setup", () => {
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
interactionId: "11111111-1111-4111-8111-111111111111",
}).success).toBe(true);
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
interactionId: "not-an-interaction",
}).success).toBe(false);
});
it("accepts a curated provider-generated URL while still requiring a source", () => {
expect(connectToolAppSchema.safeParse({
galleryKey: "zapier",
connectionMethodKey: "generated-url",
link: "https://mcp.zapier.com/api/v1/connect?token=secret-token",
}).success).toBe(true);
expect(connectToolAppSchema.safeParse({}).success).toBe(false);
});
// PAP-17087: the guided generic flow and paste-config both reach the connect
// endpoint, so unsafe header names/values are rejected once at this boundary.
it("accepts generic advanced-authentication input for a pasted URL", () => {
const parsed = connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
authMode: "custom_headers",
credentialValues: {
"headers.X-Api-Key": "phx_abc123",
"headers.X-PostHog-Project-Id": "12345",
},
});
expect(parsed.success).toBe(true);
const manualClient = connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
authMode: "oauth",
oauthClient: { clientId: "client-abc", clientSecret: "shhh" },
});
expect(manualClient.success).toBe(true);
expect(connectToolAppSchema.safeParse({
galleryKey: "asana",
oauthClient: { clientId: "customer-client", clientSecret: "customer-secret" },
}).success).toBe(true);
});
it("rejects header credentials Paperclip refuses to send", () => {
for (const configPath of ["headers.Host", "headers.Cookie", "headers.Transfer-Encoding", "headers.Sec-Fetch-Mode"]) {
const parsed = connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
credentialValues: { [configPath]: "value" },
});
expect(parsed.success, configPath).toBe(false);
}
});
it("rejects header names and values that could split the outbound request", () => {
const badName = connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
credentialValues: { "headers.X-Bad\r\nX-Injected": "value" },
});
expect(badName.success).toBe(false);
const badValue = connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
credentialValues: { "headers.X-Api-Key": "abc\r\nX-Injected: 1" },
});
expect(badValue.success).toBe(false);
if (!badValue.success) {
// The message names the header but must never echo the rejected value.
const message = badValue.error.issues[0]?.message ?? "";
expect(message).toContain("X-Api-Key");
expect(message).not.toContain("X-Injected");
}
});
it("keeps generic auth-mode selection off curated apps while allowing owned OAuth clients", () => {
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
authMode: "bearer",
}).success).toBe(false);
expect(connectToolAppSchema.safeParse({
galleryKey: "posthog",
oauthClient: { clientId: "client-abc" },
}).success).toBe(true);
});
it("allows only curated app setup to resume an exact draft", () => {
const resumeConnectionId = "11111111-1111-4111-8111-111111111111";
expect(connectToolAppSchema.safeParse({
galleryKey: "notion",
resumeConnectionId,
}).success).toBe(true);
expect(connectToolAppSchema.safeParse({
link: "https://mcp.example.test/mcp",
resumeConnectionId,
}).success).toBe(false);
});
it("accepts secret references for connection credentials", () => {
const parsed = createToolConnectionSchema.safeParse({
applicationId: "11111111-1111-4111-8111-111111111111",
name: "GitHub fixture",
connectionKind: "managed",
transportConfig: { url: "https://example.test/mcp" },
credentialSecretRefs: [
{
secretId: "22222222-2222-4222-8222-222222222222",
configPath: "headers.Authorization",
versionSelector: "latest",
},
],
});
expect(parsed.success).toBe(true);
});
it("keeps invocation payload summaries redacted and bounded", () => {
const parsed = toolRedactedValueSummarySchema.parse({
summary: "Redacted arguments: 2 fields omitted.",
sha256: "a".repeat(64),
redactedFields: ["headers.Authorization", "body.token"],
});
expect(parsed.redactedFields).toEqual(["headers.Authorization", "body.token"]);
});
});