test(CQ-ORCH-9): Add SpawnAgentDto validation tests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 14:31:37 -06:00
parent 298a379c42
commit 433212e00f

View File

@@ -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<string, unknown> {
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
});
});
});