Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
SEC-ORCH-28: Add connectTimeout (5000ms default) and commandTimeout (3000ms default) to Valkey/Redis client to prevent indefinite connection hangs. Both are configurable via VALKEY_CONNECT_TIMEOUT_MS and VALKEY_COMMAND_TIMEOUT_MS environment variables. SEC-ORCH-29: Add @ArrayMaxSize(50) and @MaxLength(2000) to workItems in AgentContextDto to prevent memory exhaustion from unbounded input. Also adds @ArrayMaxSize(20) and @MaxLength(200) to skills array. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
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);
|
|
});
|
|
|
|
// ------ workItems MaxLength / ArrayMaxSize (SEC-ORCH-29) ------ //
|
|
it("should reject workItems array exceeding max size of 50", async () => {
|
|
const payload = validSpawnPayload();
|
|
(payload.context as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
|
|
});
|
|
});
|
|
});
|