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); }); // ------ workItems MaxLength / ArrayMaxSize (SEC-ORCH-29) ------ // it("should reject workItems array exceeding max size of 50", async () => { const payload = validSpawnPayload(); (payload.context as Record).workItems = Array.from( { length: 51 }, (_, i) => `US-${String(i + 1).padStart(3, "0")}` ); const dto = plainToInstance(SpawnAgentDto, payload); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); }); it("should accept workItems array at max size of 50", async () => { const payload = validSpawnPayload(); (payload.context as Record).workItems = Array.from( { length: 50 }, (_, i) => `US-${String(i + 1).padStart(3, "0")}` ); const dto = plainToInstance(SpawnAgentDto, payload); const errors = await validate(dto); expect(errors).toHaveLength(0); }); it("should reject a work item string exceeding 2000 characters", async () => { const payload = validSpawnPayload(); (payload.context as Record).workItems = ["x".repeat(2001)]; const dto = plainToInstance(SpawnAgentDto, payload); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); }); it("should accept a work item string at exactly 2000 characters", async () => { const payload = validSpawnPayload(); (payload.context as Record).workItems = ["x".repeat(2000)]; const dto = plainToInstance(SpawnAgentDto, payload); const errors = await validate(dto); expect(errors).toHaveLength(0); }); // ------ skills MaxLength / ArrayMaxSize (SEC-ORCH-29) ------ // it("should reject skills array exceeding max size of 20", async () => { const payload = validSpawnPayload(); (payload.context as Record).skills = Array.from( { length: 21 }, (_, i) => `skill-${i}` ); const dto = plainToInstance(SpawnAgentDto, payload); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); }); it("should reject a skill string exceeding 200 characters", async () => { const payload = validSpawnPayload(); (payload.context as Record).skills = ["s".repeat(201)]; 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); }); }); });