From 41c5114a45fbd64dfb5e3ecdf6101cd4a1b1d732 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 16 Jul 2026 17:17:28 +0000 Subject: [PATCH] test(server): salvage stranded regression coverage --- ...agent-secret-binding-update-routes.test.ts | 279 ++++++++++++++++++ .../heartbeat-process-recovery.test.ts | 84 ++++++ 2 files changed, 363 insertions(+) create mode 100644 server/src/__tests__/agent-secret-binding-update-routes.test.ts diff --git a/server/src/__tests__/agent-secret-binding-update-routes.test.ts b/server/src/__tests__/agent-secret-binding-update-routes.test.ts new file mode 100644 index 00000000000..6b8ff1e9d1b --- /dev/null +++ b/server/src/__tests__/agent-secret-binding-update-routes.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express, { type Request } from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + activityLog, + agentConfigRevisions, + agents, + companies, + companySecretBindings, + companySecretProviderConfigs, + companySecretVersions, + companySecrets, + createDb, + secretAccessEvents, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; +import { secretRoutes } from "../routes/secrets.js"; +import { agentService } from "../services/agents.js"; +import { secretService } from "../services/secrets.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping agent secret binding route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("agent update secret_ref env bindings", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-update-secret-routes-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("agent-update-secret-routes"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(agentConfigRevisions); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + if (previousKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + } + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + function createApp(actor: Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use("/api", secretRoutes(db)); + app.use(errorHandler); + return app; + } + + async function seedCompany(name = "Paperclip") { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name, + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + function localBoardActor(companyId: string): Request["actor"] { + return { + type: "board", + userId: "board-user-1", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + }; + } + + it("accepts the UI update payload, syncs the binding, and exposes value-free usage metadata", async () => { + const companyId = await seedCompany(); + const secret = await secretService(db).create(companyId, { + name: `openai-${randomUUID()}`, + provider: "local_encrypted", + value: "sk-live-agent-secret", + }); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Builder", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: { + model: "gpt-5.4-mini", + promptTemplate: "Work the issue.", + }, + runtimeConfig: {}, + permissions: {}, + }); + + const app = createApp(localBoardActor(companyId)); + const updateRes = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + model: "gpt-5.4-mini", + promptTemplate: "Work the issue.", + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: secret.id, + version: "latest", + }, + }, + }, + replaceAdapterConfig: true, + }); + + expect(updateRes.status, JSON.stringify(updateRes.body)).toBe(200); + + const updated = await agentService(db).getById(agentId); + expect(updated?.adapterConfig).toMatchObject({ + model: "gpt-5.4-mini", + promptTemplate: "Work the issue.", + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: secret.id, + version: "latest", + }, + }, + }); + + const usageRes = await request(app).get(`/api/secrets/${secret.id}/usage`); + expect(usageRes.status, JSON.stringify(usageRes.body)).toBe(200); + expect(usageRes.body).toMatchObject({ + secretId: secret.id, + bindings: [ + { + secretId: secret.id, + targetType: "agent", + targetId: agentId, + configPath: "env.OPENAI_API_KEY", + versionSelector: "latest", + required: true, + target: { + type: "agent", + id: agentId, + label: "Builder", + href: "/agents/builder", + status: "idle", + }, + }, + ], + }); + expect(JSON.stringify(usageRes.body)).not.toContain("sk-live-agent-secret"); + }); + + it("rejects malformed secret_ref env payloads before persistence", async () => { + const companyId = await seedCompany(); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Validator", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const app = createApp(localBoardActor(companyId)); + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "not-a-uuid", + version: "latest", + }, + }, + }, + replaceAdapterConfig: true, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.error).toBe("Validation error"); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, agentId)); + expect(bindings).toHaveLength(0); + }); + + it("rejects cross-company secret refs during update normalization", async () => { + const companyId = await seedCompany("Paperclip"); + const foreignCompanyId = await seedCompany("Other Co"); + const foreignSecret = await secretService(db).create(foreignCompanyId, { + name: `foreign-${randomUUID()}`, + provider: "local_encrypted", + value: "sk-foreign-secret", + }); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Boundary", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const app = createApp(localBoardActor(companyId)); + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: foreignSecret.id, + version: "latest", + }, + }, + }, + replaceAdapterConfig: true, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(JSON.stringify(res.body)).toContain("Secret must belong to same company"); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, agentId)); + expect(bindings).toHaveLength(0); + }); +}); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 1432021f91e..61cc20ab1b8 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -4836,6 +4836,90 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]"); }); + it("uses a board-owned source recovery action when no invokable recovery owner has budget", async () => { + const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "todo", + runStatus: "failed", + retryReason: "assignment_recovery", + }); + await db.insert(budgetPolicies).values({ + companyId, + scopeType: "agent", + scopeId: agentId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: 1, + hardStopEnabled: true, + isActive: true, + }); + await db.insert(costEvents).values({ + companyId, + agentId, + issueId, + provider: "test", + biller: "test", + billingType: "tokens", + model: "test-model", + costCents: 1, + occurredAt: new Date(), + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + + expect(result.escalated).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(sourceIssue).toMatchObject({ + status: "blocked", + assigneeAgentId: agentId, + }); + + const [recoveryAction] = await db + .select() + .from(issueRecoveryActions) + .where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId))); + expect(recoveryAction).toMatchObject({ + companyId, + sourceIssueId: issueId, + recoveryIssueId: null, + kind: "stranded_assigned_issue", + status: "active", + ownerType: "board", + ownerAgentId: null, + previousOwnerAgentId: agentId, + returnOwnerAgentId: agentId, + cause: "process_lost", + attemptCount: 1, + }); + expect(recoveryAction?.evidence).toMatchObject({ + sourceIssueId: issueId, + previousStatus: "todo", + latestRunId: runId, + retryReason: "assignment_recovery", + }); + expect(recoveryAction?.wakePolicy).toMatchObject({ + type: "board_escalation", + reason: "no_invokable_recovery_owner", + }); + + const recoveryIssues = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery"))); + expect(recoveryIssues).toHaveLength(0); + + const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + expect(wakeups.some((wakeup) => wakeup.reason === "source_scoped_recovery_action")).toBe(false); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction?.id}\``); + expect(comments[0]?.body).toContain("Recovery owner: board escalation"); + expect(comments[0]?.body).toContain("assign an invokable recovery owner"); + }); + it("blocks an already stranded recovery issue without creating a recovery child", async () => { const { companyId, issueId } = await seedStrandedIssueFixture({ status: "todo",