12067 lines
487 KiB
TypeScript
12067 lines
487 KiB
TypeScript
import { createHash, 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,
|
|
} from "@paperclipai/db";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
APP_STORE_HIDDEN_SLUGS,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
getConnectableAppDefinition,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
classifyRisk,
|
|
normalizeConnectionMethodConfig,
|
|
projectConnectionMethodToolInputSchema,
|
|
projectConnectionMethodToolArguments,
|
|
toolAccessService,
|
|
} from "../services/tool-access.js";
|
|
import { accessService } from "../services/access.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 type { ComposioClient } from "../services/composio.js";
|
|
import type { VercelConnectClient } from "../services/vercel-connect.js";
|
|
import {
|
|
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 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 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! };
|
|
}
|
|
|
|
function fakeComposioClient(accountStatus: () => string): ComposioClient {
|
|
return {
|
|
validateApiKey: vi.fn(async () => undefined),
|
|
listToolkits: vi.fn(async () => ({ items: [{ slug: "github", name: "GitHub" }] })),
|
|
listAuthConfigs: vi.fn(async () => ({ items: [] })),
|
|
createConnectLink: vi.fn(async () => ({ link_token: "link", redirect_url: "https://composio.test/link", expires_at: new Date().toISOString() })),
|
|
listConnectedAccounts: vi.fn(async () => ({ items: [{
|
|
id: "account-github",
|
|
user_id: "paperclip:test",
|
|
status: accountStatus(),
|
|
toolkit: { slug: "github" },
|
|
auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true },
|
|
}] })),
|
|
deleteConnectedAccount: vi.fn(async () => undefined),
|
|
createSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" } })),
|
|
resumeSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" } })),
|
|
};
|
|
}
|
|
|
|
// 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").mockResolvedValue(
|
|
mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }),
|
|
);
|
|
}
|
|
|
|
const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp";
|
|
|
|
async function withGalleryServerUrl<T>(
|
|
slug: string,
|
|
serverUrl: string,
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
const method = getConnectableAppDefinition(slug)?.methods[0];
|
|
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 () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-access-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 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("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("fails autonomous token minting closed until the named agent has a standing delegation", 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",
|
|
status: "active",
|
|
isDefault: false,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
|
|
|
const denied = await request(app)
|
|
.post(`/api/agents/me/connections/${connection.id}/token`)
|
|
.send({});
|
|
expect(denied.status).toBe(409);
|
|
expect(denied.body).toMatchObject({
|
|
code: "standing_delegation_required",
|
|
grantId: grant.id,
|
|
remediation: { action: "delegate_personal_grant", grantId: grant.id, agentId: agent.id },
|
|
});
|
|
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "pending",
|
|
addresseeUserId: "user-for-run",
|
|
idempotencyKey: `connection-delegation:${connection.id}:user-for-run:${agent.id}`,
|
|
})]);
|
|
|
|
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");
|
|
});
|
|
|
|
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("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("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.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("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").mockResolvedValue(mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-tool-test",
|
|
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").mockResolvedValue(mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-tool-test",
|
|
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").mockResolvedValue(mcpHttpResponse({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-tool-test",
|
|
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: "awaiting_approval", 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([
|
|
"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",
|
|
]),
|
|
);
|
|
expect(res.body.apps).toHaveLength(35);
|
|
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("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("degrades a Composio child when its connected account becomes inactive", async () => {
|
|
const company = await createCompany(db);
|
|
const { child } = await createComposioParentAndChild(db, company.id);
|
|
const client = fakeComposioClient(() => "INACTIVE");
|
|
const service = createTestToolAccessService(db, { composioClientFactory: () => client });
|
|
|
|
await expect(service.checkHealth(child.id)).rejects.toMatchObject({
|
|
status: 502,
|
|
details: {
|
|
code: "composio_connected_account_inactive",
|
|
connection: expect.objectContaining({ id: child.id, healthStatus: "degraded" }),
|
|
},
|
|
});
|
|
await expect(service.getConnection(child.id)).resolves.toMatchObject({
|
|
healthStatus: "degraded",
|
|
healthMessage: expect.stringContaining("INACTIVE"),
|
|
});
|
|
expect(client.listConnectedAccounts).toHaveBeenCalledWith(expect.objectContaining({
|
|
toolkitSlugs: ["github"],
|
|
}));
|
|
});
|
|
|
|
it("cascades Composio parent pause, restores active children, and keeps inactive children disabled", async () => {
|
|
const company = await createCompany(db);
|
|
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
|
let accountStatus = "ACTIVE";
|
|
const service = createTestToolAccessService(db, {
|
|
composioClientFactory: () => fakeComposioClient(() => accountStatus),
|
|
});
|
|
|
|
await service.updateConnection(parent.id, { enabled: false });
|
|
await expect(service.getConnection(child.id)).resolves.toMatchObject({
|
|
enabled: false,
|
|
config: expect.objectContaining({ disabledByComposioParent: true }),
|
|
});
|
|
|
|
accountStatus = "INACTIVE";
|
|
await service.updateConnection(parent.id, { enabled: true });
|
|
await expect(service.getConnection(child.id)).resolves.toMatchObject({
|
|
enabled: false,
|
|
healthStatus: "degraded",
|
|
healthMessage: expect.stringContaining("INACTIVE"),
|
|
});
|
|
|
|
accountStatus = "ACTIVE";
|
|
await service.updateConnection(parent.id, { enabled: true });
|
|
const restored = await service.getConnection(child.id);
|
|
expect(restored).toMatchObject({ enabled: true, healthStatus: "unchecked", healthMessage: null });
|
|
expect(restored.config).not.toHaveProperty("disabledByComposioParent");
|
|
});
|
|
|
|
it("requires child-removal confirmation before deleting a Composio parent", async () => {
|
|
const company = await createCompany(db);
|
|
const { parent, child } = await createComposioParentAndChild(db, company.id);
|
|
const service = createTestToolAccessService(db);
|
|
|
|
await expect(service.archiveConnection(parent.id, company.id)).rejects.toMatchObject({
|
|
status: 409,
|
|
details: {
|
|
code: "composio_child_removal_confirmation_required",
|
|
childConnectionCount: 1,
|
|
},
|
|
});
|
|
await expect(service.archiveConnection(parent.id, company.id, undefined, {
|
|
confirmComposioChildren: true,
|
|
})).resolves.toMatchObject({ connection: expect.objectContaining({ status: "archived" }) });
|
|
await expect(service.getConnection(child.id)).resolves.toMatchObject({ status: "archived", enabled: false });
|
|
});
|
|
|
|
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("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("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("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("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("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 };
|
|
const gmailDefinition = getConnectableAppDefinition("gmail")!;
|
|
const previousOwnershipAvailability = gmailDefinition.ownershipAvailability;
|
|
gmailDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true };
|
|
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 {
|
|
gmailDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
if (deadline) clearTimeout(deadline);
|
|
await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined);
|
|
}
|
|
}, 15_000);
|
|
|
|
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);
|
|
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")!;
|
|
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([
|
|
"credentials.oauth.access_token",
|
|
"oauth.access_token",
|
|
"oauth.refresh_token",
|
|
]);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("activates allowed Drive write actions with recommended 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");
|
|
const [approvalPolicy] = await db.select().from(toolPolicies).where(and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
));
|
|
expect(approvalPolicy).toMatchObject({
|
|
policyType: "require_approval",
|
|
selectors: expect.objectContaining({ catalogEntryId: createEntry.id }),
|
|
});
|
|
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));
|
|
await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, approvalPolicy!.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.id, approvalPolicy!.id)))
|
|
.resolves.toEqual([expect.objectContaining({ enabled: false })]);
|
|
} finally {
|
|
driveDefinition.ownershipAvailability = previousOwnershipAvailability;
|
|
}
|
|
});
|
|
|
|
it("keeps a managed draft retryable when recommended-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 }),
|
|
]);
|
|
const createEntry = completed.catalog.find((entry) => entry.toolName === "create_file")!;
|
|
await expect(db.select().from(toolPolicies).where(and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
))).resolves.toEqual([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
selectors: expect.objectContaining({ catalogEntryId: createEntry.id }),
|
|
}),
|
|
]);
|
|
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 }),
|
|
]);
|
|
const createEntry = completed.catalog.find((entry) => entry.toolName === "create_file")!;
|
|
await expect(db.select().from(toolPolicies).where(and(
|
|
eq(toolPolicies.companyId, company.id),
|
|
eq(toolPolicies.enabled, true),
|
|
))).resolves.toEqual([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
selectors: expect.objectContaining({ catalogEntryId: createEntry.id }),
|
|
}),
|
|
]);
|
|
} 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");
|
|
});
|
|
|
|
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" });
|
|
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("activates and discovers actions for a fresh personal OAuth callback before access is finalized", 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, [], "owner");
|
|
const agent = await createAgent(db, company.id);
|
|
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,
|
|
});
|
|
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([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
selectors: expect.objectContaining({ catalogEntryId: sendMessageEntry.id }),
|
|
}),
|
|
]);
|
|
const callbackPolicy = toolAccessPolicyService(db);
|
|
const decide = (entry: (typeof completed.catalog)[number]) => callbackPolicy.decide({
|
|
companyId: company.id,
|
|
actor: { actorType: "agent", actorId: agent.id, agentId: agent.id },
|
|
request: {
|
|
connectionId: connected.connectionId,
|
|
catalogEntryId: entry.id,
|
|
toolName: entry.toolName,
|
|
arguments: {},
|
|
},
|
|
});
|
|
await expect(decide(searchMessagesEntry)).resolves.toMatchObject({
|
|
decision: "allow",
|
|
reasonCode: "allow_profile",
|
|
});
|
|
await expect(decide(sendMessageEntry)).resolves.toMatchObject({
|
|
decision: "require_approval",
|
|
reasonCode: "requires_approval_policy",
|
|
});
|
|
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 });
|
|
expect(finished.profileEntries).toHaveLength(2);
|
|
expect(finished.profileBindings).toEqual([
|
|
expect.objectContaining({ targetType: "company", targetId: company.id }),
|
|
]);
|
|
await expect(db.select().from(toolConnectionInstalls).where(and(
|
|
eq(toolConnectionInstalls.connectionId, connected.connectionId),
|
|
eq(toolConnectionInstalls.targetType, "company"),
|
|
))).resolves.toHaveLength(1);
|
|
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 },
|
|
});
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("returns a pre-scoped personal Notion callback directly to Test", 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();
|
|
|
|
const callbackRes = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.set("Accept", "text/html")
|
|
.query({ state, code: "notion-choice-code" });
|
|
|
|
expect(callbackRes.status).toBe(303);
|
|
expect(callbackRes.headers.location).toBe(
|
|
`/${company.issuePrefix}/apps/${connectRes.body.connectionId}/test?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("starts and completes OAuth app sign-in with PKCE state and secret-backed tokens", 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", "https://paperclip-public.example");
|
|
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`)
|
|
.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("https://paperclip-public.example/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("https://paperclip-public.example/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")
|
|
.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`)
|
|
.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("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}/test?success=1`,
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
|
|
await expect(db.select().from(companySecretBindings)).resolves.toHaveLength(6);
|
|
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")
|
|
.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")).toBeNull();
|
|
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: [],
|
|
},
|
|
});
|
|
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([
|
|
expect.objectContaining({
|
|
policyType: "require_approval",
|
|
enabled: true,
|
|
selectors: expect.objectContaining({
|
|
catalogEntryId: completed.actions.canMakeChanges[0]!.catalogEntryId,
|
|
}),
|
|
}),
|
|
]);
|
|
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("preserves the provider's DCR client-auth ordering for Miro token exchange", async () => {
|
|
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 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;
|
|
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");
|
|
});
|
|
|
|
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("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",
|
|
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",
|
|
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("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",
|
|
name: "Asana own app",
|
|
oauthClient: {
|
|
clientId: "asana-first-client",
|
|
clientSecret: "asana-first-secret",
|
|
},
|
|
});
|
|
|
|
const resumed = await service.connectGalleryApp(company.id, {
|
|
galleryKey: "asana",
|
|
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("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: "credentials.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" }),
|
|
});
|
|
}
|
|
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("fails expired OAuth credentials without a refresh token and returns reconnect links", 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 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: 502,
|
|
details: expect.objectContaining({
|
|
code: "oauth_refresh_missing",
|
|
setupUrl: `/apps/${connect.connectionId}/setup`,
|
|
reconnectUrl: `/apps/${connect.connectionId}/advanced`,
|
|
connection: expect.objectContaining({ healthStatus: "failed" }),
|
|
}),
|
|
});
|
|
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("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 and revives a removed gallery app without requiring its applicationId", 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("per_user");
|
|
await expect(db.select().from(toolApplications)).resolves.toHaveLength(1);
|
|
await expect(db.select().from(toolConnections)).resolves.toHaveLength(1);
|
|
});
|
|
|
|
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(502);
|
|
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([
|
|
["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(502);
|
|
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();
|
|
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");
|
|
});
|
|
|
|
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("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",
|
|
name: "GitHub workspace",
|
|
credentialValues: { "credentials.authorization": "zap-secret" },
|
|
}, { actorType: "user", actorId: "board" }));
|
|
|
|
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",
|
|
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)));
|
|
|
|
await db.insert(toolProfiles).values({
|
|
companyId: company.id,
|
|
profileKey: `conflict-${randomUUID()}`,
|
|
name: "Conflicting app profile",
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
});
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ name: "Conflicting app profile", updatedAt: new Date() })
|
|
.where(eq(toolConnections.id, connect.connectionId));
|
|
|
|
await expect(service.finishGalleryAppConnection(company.id, connect.connectionId, {
|
|
enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
|
|
askFirstCatalogEntryIds: [updateEntry.id],
|
|
access: { agentIds: [agent.id] },
|
|
}, { actorType: "user", actorId: "board" })).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",
|
|
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("Paste a new key") });
|
|
|
|
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",
|
|
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",
|
|
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 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 });
|
|
const [updatedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
|
|
|
expect(sweep).toMatchObject({
|
|
checked: 1,
|
|
healthy: 0,
|
|
failed: 1,
|
|
failedConnectionIds: [connection.id],
|
|
});
|
|
expect(updatedConnection).toMatchObject({
|
|
healthStatus: "error",
|
|
healthMessage: "revoked token",
|
|
lastError: "revoked token",
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
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("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("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" },
|
|
},
|
|
});
|
|
});
|
|
});
|