fix: close remaining routine merge blockers

This commit is contained in:
dotta 2026-03-20 16:40:27 -05:00
parent 9093cfbe4f
commit 8dc98db717
7 changed files with 182 additions and 13 deletions

View file

@ -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();
});
});

View file

@ -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<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
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<string, unknown>) {
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",

View file

@ -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({

View file

@ -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(