mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
DELETE /tool-applications/:applicationId pre-checked for connections and then deleted in a later statement. Because tool_connections.application_id was ON DELETE CASCADE, a connection created in that gap was silently cascade-deleted instead of forcing the promised 409. Switch the FK to ON DELETE NO ACTION so the database fails closed: an application with connections can never be deleted, and a concurrently inserted connection (which takes a FOR KEY SHARE lock on the parent row) forces the delete to raise a foreign_key_violation rather than cascade. NO ACTION (end-of-statement check) rather than RESTRICT (immediate) keeps company teardown cascading cleanly. The service still pre-checks for a friendly 409 with connectionCount, and now also translates a 23503 violation on the connection FK into the same 409 so the race surfaces the conflict instead of a 500. Regression tests: the raw delete is rejected and both rows survive (the old cascade would have silently dropped the connection), and a company delete still cascades application + connection away. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1107 lines
39 KiB
TypeScript
1107 lines
39 KiB
TypeScript
import { 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,
|
|
companies,
|
|
companySecretBindings,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueThreadInteractions,
|
|
issues,
|
|
toolAccessAuditEvents,
|
|
toolActionRequests,
|
|
toolApplications,
|
|
toolCallEvents,
|
|
toolCatalogEntries,
|
|
toolConnections,
|
|
toolInvocations,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolRuntimeSlots,
|
|
} from "@paperclipai/db";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { toolAccessRoutes } from "../routes/tool-access.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
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]!);
|
|
}
|
|
|
|
function mockToolsList(tools: unknown[]) {
|
|
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }),
|
|
} as Response);
|
|
}
|
|
|
|
function createRouteApp(db: ReturnType<typeof createDb>, actor?: Express.Request["actor"]) {
|
|
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));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
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();
|
|
await db.delete(companySecretBindings);
|
|
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(toolRuntimeSlots);
|
|
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(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("registers a remote MCP connection and quarantines new or changed write tools during catalog refresh", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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: "remote_http",
|
|
config: { url: "https://fixture.example/mcp" },
|
|
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(1);
|
|
expect(firstRefresh.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "search_notes", status: "active", riskLevel: "read" }),
|
|
expect.objectContaining({
|
|
toolName: "send_email",
|
|
status: "quarantined",
|
|
riskLevel: "write",
|
|
quarantineReason: "new_write_tool",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({ status: "active", reviewedAt: new Date(), quarantineReason: null, quarantinedAt: null })
|
|
.where(eq(toolCatalogEntries.toolName, "send_email"));
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
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 },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
} as Response);
|
|
|
|
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: "changed_write_tool",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("registers an approved local stdio template and exposes its runtime slot", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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("creates profiles with entries, binds them to agents, and resolves effective allowed tools", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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()}`,
|
|
transport: "remote_http",
|
|
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("installs the safe example fixture idempotently and smokes allow, deny, and audit paths", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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"]);
|
|
expect(install.catalog).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ toolName: "list_items", status: "active", riskLevel: "read" }),
|
|
expect.objectContaining({ toolName: "set_value", 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("stops and restarts local stdio runtime slots through the board service", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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 = toolAccessService(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("updates tool applications through the board route and records activity", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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 = toolAccessService(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 403 for cross-company application updates and 404 for missing applications", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const application = await toolAccessService(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(403);
|
|
expect(missingRes.status).toBe(404);
|
|
});
|
|
|
|
it("deletes an application with zero connections and records activity", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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 = toolAccessService(db);
|
|
const app = createRouteApp(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Guarded connection",
|
|
transport: "remote_http",
|
|
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("fails closed at the database when a connection races an application delete (no silent cascade)", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Racy connection",
|
|
transport: "remote_http",
|
|
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 = toolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Company-scoped connection",
|
|
transport: "remote_http",
|
|
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 403 for cross-company application deletes and 404 for missing applications", async () => {
|
|
const allowedCompany = await createCompany(db);
|
|
const otherCompany = await createCompany(db);
|
|
const application = await toolAccessService(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(403);
|
|
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",
|
|
transport: "remote_http",
|
|
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 toolAccessService(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("rejects runtime controls for non-local runtime kinds", async () => {
|
|
const company = await createCompany(db);
|
|
const service = toolAccessService(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",
|
|
transport: "remote_http",
|
|
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: "remote_http",
|
|
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: "remote_http",
|
|
}),
|
|
});
|
|
});
|
|
|
|
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 = toolAccessService(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",
|
|
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,
|
|
});
|
|
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: "not_instrumented" });
|
|
});
|
|
|
|
it("rejects enabled local stdio connections in public hosted mode without a trusted runtime host", async () => {
|
|
const company = await createCompany(db);
|
|
const hostedService = toolAccessService(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 = toolAccessService(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 toolAccessService(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: "remote_http",
|
|
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 = toolAccessService(db);
|
|
const connection = await service.createConnection(company.id, {
|
|
name: "Secret-backed remote",
|
|
transport: "remote_http",
|
|
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);
|
|
|
|
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: "remote_http" },
|
|
});
|
|
expect(JSON.stringify(audit)).not.toContain("Bearer ");
|
|
expect(JSON.stringify(audit)).not.toContain("Authorization");
|
|
});
|
|
});
|