From 3a7a37801168261cae0fbbb8359a7124eb5c2ac4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 00:09:20 +0000 Subject: [PATCH] test(connections): align protocol fixtures with grant defaults Co-Authored-By: Paperclip --- .../src/__tests__/tool-access-service.test.ts | 48 +++++++++++++++---- .../__tests__/tool-gateway-service.test.ts | 1 + server/src/services/tool-access.ts | 26 +++++++++- server/src/services/tool-gateway.ts | 21 ++++---- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 91f311610df..e8f59446555 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -47,7 +47,7 @@ import { classifyRisk, normalizeConnectionMethodConfig, toolAccessService } from 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, type ToolGatewayService } from "../services/tool-gateway.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"; @@ -70,6 +70,16 @@ function createTestToolAccessService( }); } +function createToolGatewayService( + db: ReturnType, + options: NonNullable[1]> = {}, +) { + return createToolGatewayServiceBase(db, { + remoteHttpRequest: async (url, init) => fetch(url, init), + ...options, + }); +} + async function createCompany(db: ReturnType) { return db .insert(companies) @@ -142,6 +152,7 @@ function createRouteApp( deploymentMode: "local_trusted" | "authenticated"; deploymentExposure: "private" | "public"; }, + useProtocolFixtureTransport = true, ) { const app = express(); app.use(express.json()); @@ -158,8 +169,12 @@ function createRouteApp( }); app.use("/api", toolAccessRoutes(db, { toolGateway, - remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], - remoteHttpRequest: async (url, init) => fetch(url, init), + ...(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); @@ -441,7 +456,16 @@ async function createRemoteToolFixture( 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, @@ -3139,8 +3163,8 @@ describeEmbeddedPostgres("tool access service", () => { 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: "quarantined" }), - expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "quarantined" }), + expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "active" }), + expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "active" }), ])); }); @@ -3275,7 +3299,6 @@ describeEmbeddedPostgres("tool access service", () => { 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.", @@ -3722,6 +3745,7 @@ describeEmbeddedPostgres("tool access service", () => { 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) @@ -3736,7 +3760,7 @@ describeEmbeddedPostgres("tool access service", () => { 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().from(toolProfileBindings)).resolves.toHaveLength(0); + await expect(db.select({ id: toolProfileBindings.id }).from(toolProfileBindings)).resolves.toEqual(bindingsBefore); }); it("binds OAuth callback completion to the initiating board session", async () => { @@ -3745,8 +3769,12 @@ describeEmbeddedPostgres("tool access service", () => { 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 bound" }); 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`) @@ -5183,7 +5211,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(connect.connection).toMatchObject({ status: "draft", enabled: false, - config: { url: "https://links.example.test/actions", quarantineNewEntries: true }, + config: { url: "https://links.example.test/actions", quarantineNewEntries: false }, credentialSecretRefs: [ expect.objectContaining({ configPath: "credentials.authorization", @@ -5226,7 +5254,7 @@ describeEmbeddedPostgres("tool access service", () => { ["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); + 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") { diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 66c04190727..047b7140b27 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -42,6 +42,7 @@ function createTestToolGatewayService(db: ReturnType, options: return createToolGatewayService(db, { ...options, toolActionSigningSecret: options.toolActionSigningSecret ?? testToolActionSigningSecret, + remoteHttpRequest: options.remoteHttpRequest ?? (async (url, init) => fetch(url, init)), }); } diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index f7e74c28cc4..1184d8cb541 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -3039,10 +3039,21 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .where(and(eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, profileKey))) .limit(1); if (!profile) { + const [sameName] = await dbClient + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where(and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.name, connection.name), + )) + .limit(1); + const profileName = sameName + ? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})` + : connection.name; [profile] = await dbClient.insert(toolProfiles).values({ companyId: connection.companyId, profileKey, - name: connection.name, + name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", @@ -3082,10 +3093,21 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .limit(1); const createdProfile = !profile; if (!profile) { + const [sameName] = await db + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where(and( + eq(toolProfiles.companyId, input.connection.companyId), + eq(toolProfiles.name, input.connection.name), + )) + .limit(1); + const profileName = sameName + ? `${input.connection.name} (${input.connection.id.replace(/-/g, "").slice(0, 8)})` + : input.connection.name; [profile] = await db.insert(toolProfiles).values({ companyId: input.connection.companyId, profileKey, - name: input.connection.name, + name: profileName, description: `Access profile for ${input.connection.name}.`, status: "active", defaultAction: "deny", diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 83c0a6deef5..7c0816b06c7 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -775,6 +775,8 @@ export function createToolGatewayService( trustedLocalStdioRuntimeHost?: string | null; runtimeSupervisor?: ToolRuntimeSupervisorOptions; toolActionSigningSecret?: string; + /** Test seam for deterministic remote MCP protocol fixtures. */ + remoteHttpRequest?: (url: string, init: RequestInit) => Promise; mcpGatewayProtocolLimits?: Partial<{ authFailures: Partial; gatewayRequests: Partial; @@ -3307,7 +3309,7 @@ export function createToolGatewayService( // address it approved, so an operator-supplied hostname cannot be rebound // onto a loopback or metadata address between validation and dispatch // (PAP-17098). - const response = await guardedRemoteHttpFetch(endpoint, { + const requestInit: RequestInit = { method: "POST", redirect: "manual", // MCP Streamable HTTP requires the Accept header advertising both a JSON @@ -3323,13 +3325,16 @@ export function createToolGatewayService( arguments: parameters ?? {}, }, }), - }, { - ...remoteHttpFetchOptions(), - // This call site owns a caller-set budget that can exceed the - // transport's default response deadline, so hand it down rather than - // letting the tighter default cut a legitimately slow tool short. - responseTimeoutMs: ms, - }); + }; + const response = options.remoteHttpRequest + ? await options.remoteHttpRequest(endpoint, requestInit) + : await guardedRemoteHttpFetch(endpoint, requestInit, { + ...remoteHttpFetchOptions(), + // This call site owns a caller-set budget that can exceed the + // transport's default response deadline, so hand it down rather than + // letting the tighter default cut a legitimately slow tool short. + responseTimeoutMs: ms, + }); const body = await readBoundedRemoteResponse(response); execution.response = { httpStatus: response.status,