From 433212e00fd2577d23d5ada5054be85f31e578e7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Feb 2026 14:31:37 -0600 Subject: [PATCH] test(CQ-ORCH-9): Add SpawnAgentDto validation tests Adds 23 dedicated DTO-level validation tests for SpawnAgentDto and AgentContextDto using plainToInstance + validate() from class-validator. Covers: valid payloads, missing/empty taskId, invalid agentType, empty repository/branch, empty workItems, shell injection in branch names, SSRF in repository URLs, file:// protocol blocking, option injection, and invalid gateProfile values. Replaces the 5 controller-level validation tests removed in CQ-ORCH-9 with proper DTO-level equivalents. Co-Authored-By: Claude Opus 4.6 --- .../api/agents/dto/spawn-agent.dto.spec.ts | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 apps/orchestrator/src/api/agents/dto/spawn-agent.dto.spec.ts diff --git a/apps/orchestrator/src/api/agents/dto/spawn-agent.dto.spec.ts b/apps/orchestrator/src/api/agents/dto/spawn-agent.dto.spec.ts new file mode 100644 index 0000000..8e1757c --- /dev/null +++ b/apps/orchestrator/src/api/agents/dto/spawn-agent.dto.spec.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { SpawnAgentDto, AgentContextDto } from "./spawn-agent.dto"; + +/** + * Builds a valid SpawnAgentDto plain object for use as a baseline. + * Individual tests override specific fields to trigger validation failures. + */ +function validSpawnPayload(): Record { + return { + taskId: "task-abc-123", + agentType: "worker", + context: { + repository: "https://git.example.com/org/repo.git", + branch: "feature/my-branch", + workItems: ["US-001"], + }, + }; +} + +describe("SpawnAgentDto validation", () => { + // ------------------------------------------------------------------ // + // Happy path + // ------------------------------------------------------------------ // + it("should pass validation for a valid spawn request", async () => { + const dto = plainToInstance(SpawnAgentDto, validSpawnPayload()); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should pass validation with optional gateProfile", async () => { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + gateProfile: "strict", + }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should pass validation with optional skills array", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).skills = ["skill-a", "skill-b"]; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + // ------------------------------------------------------------------ // + // taskId validation + // ------------------------------------------------------------------ // + describe("taskId", () => { + it("should reject missing taskId", async () => { + const payload = validSpawnPayload(); + delete payload.taskId; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const taskIdError = errors.find((e) => e.property === "taskId"); + expect(taskIdError).toBeDefined(); + }); + + it("should reject empty-string taskId", async () => { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + taskId: "", + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const taskIdError = errors.find((e) => e.property === "taskId"); + expect(taskIdError).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------ // + // agentType validation + // ------------------------------------------------------------------ // + describe("agentType", () => { + it("should reject invalid agentType value", async () => { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + agentType: "hacker", + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const agentTypeError = errors.find((e) => e.property === "agentType"); + expect(agentTypeError).toBeDefined(); + }); + + it("should accept all valid agentType values", async () => { + for (const validType of ["worker", "reviewer", "tester"]) { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + agentType: validType, + }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + } + }); + }); + + // ------------------------------------------------------------------ // + // gateProfile validation + // ------------------------------------------------------------------ // + describe("gateProfile", () => { + it("should reject invalid gateProfile value", async () => { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + gateProfile: "invalid-profile", + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const gateError = errors.find((e) => e.property === "gateProfile"); + expect(gateError).toBeDefined(); + }); + + it("should accept all valid gateProfile values", async () => { + for (const profile of ["strict", "standard", "minimal", "custom"]) { + const dto = plainToInstance(SpawnAgentDto, { + ...validSpawnPayload(), + gateProfile: profile, + }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + } + }); + }); + + // ------------------------------------------------------------------ // + // Nested AgentContextDto validation + // ------------------------------------------------------------------ // + describe("context (nested AgentContextDto)", () => { + // ------ repository ------ // + it("should reject empty repository", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).repository = ""; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject SSRF repository URL pointing to localhost", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).repository = "https://127.0.0.1/evil/repo.git"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject SSRF repository URL pointing to private network", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).repository = + "https://192.168.1.100/org/repo.git"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject repository URL with file:// protocol", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).repository = "file:///etc/passwd"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject repository URL with dangerous characters", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).repository = + "https://git.example.com/repo;rm -rf /"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + // ------ branch ------ // + it("should reject empty branch", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).branch = ""; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject shell injection in branch name via $(command)", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).branch = "$(rm -rf /)"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject shell injection in branch name via backticks", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).branch = "`whoami`"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject branch name with semicolon injection", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).branch = "main;cat /etc/passwd"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject branch name starting with hyphen (option injection)", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).branch = "--delete"; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + // ------ workItems ------ // + it("should reject empty workItems array", async () => { + const payload = validSpawnPayload(); + (payload.context as Record).workItems = []; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it("should reject missing workItems", async () => { + const payload = validSpawnPayload(); + delete (payload.context as Record).workItems; + const dto = plainToInstance(SpawnAgentDto, payload); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + // ------------------------------------------------------------------ // + // Standalone AgentContextDto validation + // ------------------------------------------------------------------ // + describe("AgentContextDto standalone", () => { + it("should pass validation for a valid context", async () => { + const dto = plainToInstance(AgentContextDto, { + repository: "https://git.example.com/org/repo.git", + branch: "feature/my-branch", + workItems: ["US-001", "US-002"], + }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should reject non-string items in workItems", async () => { + const dto = plainToInstance(AgentContextDto, { + repository: "https://git.example.com/org/repo.git", + branch: "main", + workItems: [123, true], + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + }); +});