diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index 626f8717..aff95a7d 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; @@ -61,8 +61,21 @@ describe("boardMutationGuard", () => { }); it("does not block authenticated agent mutations", async () => { - const app = createApp("agent"); - const res = await request(app).post("/mutate").send({ ok: true }); - expect(res.status).toBe(204); + const middleware = boardMutationGuard(); + const req = { + method: "POST", + actor: { type: "agent", agentId: "agent-1" }, + header: () => undefined, + } as any; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as any; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 9fabef46..ab7c3d0d 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { companyRoutes } from "../routes/companies.js"; -import { errorHandler } from "../middleware/index.js"; const mockCompanyService = vi.hoisted(() => ({ list: vi.fn(), @@ -44,7 +42,9 @@ vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, })); -function createApp(actor: Record) { +async function createApp(actor: Record) { + const { companyRoutes } = await import("../routes/companies.js"); + const { errorHandler } = await import("../middleware/index.js"); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -58,6 +58,7 @@ function createApp(actor: Record) { describe("company portability routes", () => { beforeEach(() => { + vi.resetModules(); mockAgentService.getById.mockReset(); mockCompanyPortabilityService.exportBundle.mockReset(); mockCompanyPortabilityService.previewExport.mockReset(); @@ -72,7 +73,7 @@ describe("company portability routes", () => { companyId: "11111111-1111-4111-8111-111111111111", role: "engineer", }); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "11111111-1111-4111-8111-111111111111", @@ -104,7 +105,7 @@ describe("company portability routes", () => { warnings: [], paperclipExtensionPath: ".paperclip.yaml", }); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "11111111-1111-4111-8111-111111111111", @@ -128,7 +129,7 @@ describe("company portability routes", () => { companyId: "11111111-1111-4111-8111-111111111111", role: "ceo", }); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "11111111-1111-4111-8111-111111111111", @@ -151,7 +152,7 @@ describe("company portability routes", () => { }); it("keeps global import preview routes board-only", async () => { - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "11111111-1111-4111-8111-111111111111", diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index b6495571..0c3c0b2b 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -32,6 +32,34 @@ const routine = { createdAt: new Date("2026-03-20T00:00:00.000Z"), updatedAt: new Date("2026-03-20T00:00:00.000Z"), }; +const pausedRoutine = { + ...routine, + status: "paused", +}; +const trigger = { + id: "66666666-6666-4666-8666-666666666666", + companyId, + routineId, + kind: "schedule", + label: "weekday", + enabled: false, + cronExpression: "0 10 * * 1-5", + timezone: "UTC", + nextRunAt: null, + lastFiredAt: null, + publicId: null, + secretId: null, + signingMode: null, + replayWindowSec: null, + lastRotatedAt: null, + lastResult: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), +}; const mockRoutineService = vi.hoisted(() => ({ list: vi.fn(), @@ -78,7 +106,13 @@ describe("routine routes", () => { vi.clearAllMocks(); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); + mockRoutineService.getTrigger.mockResolvedValue(trigger); mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId }); + mockRoutineService.runRoutine.mockResolvedValue({ + id: "run-1", + source: "manual", + status: "issue_created", + }); mockAccessService.canUser.mockResolvedValue(false); mockLogActivity.mockResolvedValue(undefined); }); @@ -125,6 +159,87 @@ describe("routine routes", () => { expect(mockRoutineService.update).not.toHaveBeenCalled(); }); + it("requires tasks:assign permission to reactivate a routine", async () => { + mockRoutineService.get.mockResolvedValue(pausedRoutine); + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/routines/${routineId}`) + .send({ + status: "active", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("tasks:assign"); + expect(mockRoutineService.update).not.toHaveBeenCalled(); + }); + + it("requires tasks:assign permission to create a trigger", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/routines/${routineId}/triggers`) + .send({ + kind: "schedule", + cronExpression: "0 10 * * *", + timezone: "UTC", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("tasks:assign"); + expect(mockRoutineService.createTrigger).not.toHaveBeenCalled(); + }); + + it("requires tasks:assign permission to update a trigger", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/routine-triggers/${trigger.id}`) + .send({ + enabled: true, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("tasks:assign"); + expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled(); + }); + + it("requires tasks:assign permission to manually run a routine", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/routines/${routineId}/run`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("tasks:assign"); + expect(mockRoutineService.runRoutine).not.toHaveBeenCalled(); + }); + it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); const app = createApp({ diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 54a56f54..ee2e261e 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -430,6 +430,27 @@ describe("routine service live-execution coalescing", () => { expect(routineIssues).toHaveLength(1); }); + it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => { + const { routine, svc } = await seedFixture({ + wakeup: async () => { + throw new Error("queue unavailable"); + }, + }); + + const run = await svc.runRoutine(routine.id, { source: "manual" }); + + expect(run.status).toBe("failed"); + expect(run.failureReason).toContain("queue unavailable"); + expect(run.linkedIssueId).toBeNull(); + + const routineIssues = await db + .select({ id: issues.id }) + .from(issues) + .where(eq(issues.originId, routine.id)); + + expect(routineIssues).toHaveLength(0); + }); + it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => { const { routine, svc } = await seedFixture(); const { trigger, secretMaterial } = await svc.createTrigger( diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index 5560fbb6..e7887e88 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -101,6 +101,13 @@ export function routineRoutes(db: Db) { if (assigneeWillChange) { await assertBoardCanAssignTasks(req, routine.companyId); } + const statusWillActivate = + req.body.status !== undefined && + req.body.status === "active" && + routine.status !== "active"; + if (statusWillActivate) { + await assertBoardCanAssignTasks(req, routine.companyId); + } if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) { throw forbidden("Agents can only assign routines to themselves"); } @@ -141,6 +148,7 @@ export function routineRoutes(db: Db) { res.status(404).json({ error: "Routine not found" }); return; } + await assertBoardCanAssignTasks(req, routine.companyId); const created = await svc.createTrigger(routine.id, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, @@ -171,6 +179,7 @@ export function routineRoutes(db: Db) { res.status(404).json({ error: "Routine not found" }); return; } + await assertBoardCanAssignTasks(req, routine.companyId); const updated = await svc.updateTrigger(trigger.id, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, @@ -257,6 +266,7 @@ export function routineRoutes(db: Db) { res.status(404).json({ error: "Routine not found" }); return; } + await assertBoardCanAssignTasks(req, routine.companyId); const run = await svc.runRoutine(routine.id, req.body); const actor = getActorInfo(req); await logActivity(db, { diff --git a/server/src/services/issue-assignment-wakeup.ts b/server/src/services/issue-assignment-wakeup.ts index 96eaa982..10f10841 100644 --- a/server/src/services/issue-assignment-wakeup.ts +++ b/server/src/services/issue-assignment-wakeup.ts @@ -26,6 +26,7 @@ export function queueIssueAssignmentWakeup(input: { contextSource: string; requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; + rethrowOnError?: boolean; }) { if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return; @@ -39,5 +40,9 @@ export function queueIssueAssignmentWakeup(input: { requestedByActorId: input.requestedByActorId ?? null, contextSnapshot: { issueId: input.issue.id, source: input.contextSource }, }) - .catch((err) => logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment")); + .catch((err) => { + logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment"); + if (input.rethrowOnError) throw err; + return null; + }); } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 8eb465f2..f6fdb26f 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -561,6 +561,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup ? nextCronTickInTimeZone(input.trigger.cronExpression, input.trigger.timezone, triggeredAt) : undefined; + let createdIssue: Awaited> | null = null; try { const activeIssue = await findLiveExecutionIssue(input.routine, txDb); if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") { @@ -582,7 +583,6 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup return updated ?? createdRun; } - let createdIssue; try { createdIssue = await issueSvc.create(input.routine.companyId, { projectId: input.routine.projectId, @@ -637,6 +637,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup mutation: "create", contextSource: "routine.dispatch", requestedByActorType: input.source === "schedule" ? "system" : undefined, + rethrowOnError: true, }); const updated = await finalizeRun(createdRun.id, { status: "issue_created", @@ -652,6 +653,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup }, txDb); return updated ?? createdRun; } catch (error) { + if (createdIssue) { + await txDb.delete(issues).where(eq(issues.id, createdIssue.id)); + } const failureReason = error instanceof Error ? error.message : String(error); const failed = await finalizeRun(createdRun.id, { status: "failed",