Files
stack/apps/api/src/brain/brain-search-validation.spec.ts
Jason Woltje 17cfeb974b
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-API-19+20): Validate brain search length and limit params
- Add @MaxLength(500) to BrainQueryDto.query and BrainQueryDto.search fields
- Create BrainSearchDto with validated q (max 500 chars) and limit (1-100) fields
- Update BrainController.search to use BrainSearchDto instead of raw query params
- Add defensive validation in BrainService.search and BrainService.query methods:
  - Reject search terms exceeding 500 characters with BadRequestException
  - Clamp limit to valid range [1, 100] for defense-in-depth
- Add comprehensive tests for DTO validation and service-level guards
- Update existing controller tests for new search method signature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 13:29:03 -06:00

235 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { BadRequestException } from "@nestjs/common";
import { BrainSearchDto, BrainQueryDto } from "./dto";
import { BrainService } from "./brain.service";
import { PrismaService } from "../prisma/prisma.service";
describe("Brain Search Validation", () => {
describe("BrainSearchDto", () => {
it("should accept a valid search query", async () => {
const dto = plainToInstance(BrainSearchDto, { q: "meeting notes", limit: 10 });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should accept empty query params", async () => {
const dto = plainToInstance(BrainSearchDto, {});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should reject search query exceeding 500 characters", async () => {
const longQuery = "a".repeat(501);
const dto = plainToInstance(BrainSearchDto, { q: longQuery });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const qError = errors.find((e) => e.property === "q");
expect(qError).toBeDefined();
expect(qError?.constraints?.maxLength).toContain("500");
});
it("should accept search query at exactly 500 characters", async () => {
const maxQuery = "a".repeat(500);
const dto = plainToInstance(BrainSearchDto, { q: maxQuery });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should reject negative limit", async () => {
const dto = plainToInstance(BrainSearchDto, { q: "test", limit: -1 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
expect(limitError?.constraints?.min).toContain("1");
});
it("should reject zero limit", async () => {
const dto = plainToInstance(BrainSearchDto, { q: "test", limit: 0 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
});
it("should reject limit exceeding 100", async () => {
const dto = plainToInstance(BrainSearchDto, { q: "test", limit: 101 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
expect(limitError?.constraints?.max).toContain("100");
});
it("should accept limit at boundaries (1 and 100)", async () => {
const dto1 = plainToInstance(BrainSearchDto, { limit: 1 });
const errors1 = await validate(dto1);
expect(errors1).toHaveLength(0);
const dto100 = plainToInstance(BrainSearchDto, { limit: 100 });
const errors100 = await validate(dto100);
expect(errors100).toHaveLength(0);
});
it("should reject non-integer limit", async () => {
const dto = plainToInstance(BrainSearchDto, { limit: 10.5 });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError).toBeDefined();
});
});
describe("BrainQueryDto search and query length validation", () => {
it("should reject query exceeding 500 characters", async () => {
const longQuery = "a".repeat(501);
const dto = plainToInstance(BrainQueryDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
query: longQuery,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const queryError = errors.find((e) => e.property === "query");
expect(queryError).toBeDefined();
expect(queryError?.constraints?.maxLength).toContain("500");
});
it("should reject search exceeding 500 characters", async () => {
const longSearch = "b".repeat(501);
const dto = plainToInstance(BrainQueryDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
search: longSearch,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const searchError = errors.find((e) => e.property === "search");
expect(searchError).toBeDefined();
expect(searchError?.constraints?.maxLength).toContain("500");
});
it("should accept query at exactly 500 characters", async () => {
const maxQuery = "a".repeat(500);
const dto = plainToInstance(BrainQueryDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
query: maxQuery,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should accept search at exactly 500 characters", async () => {
const maxSearch = "b".repeat(500);
const dto = plainToInstance(BrainQueryDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
search: maxSearch,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("BrainService.search defensive validation", () => {
let service: BrainService;
let prisma: {
task: { findMany: ReturnType<typeof vi.fn> };
event: { findMany: ReturnType<typeof vi.fn> };
project: { findMany: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
prisma = {
task: { findMany: vi.fn().mockResolvedValue([]) },
event: { findMany: vi.fn().mockResolvedValue([]) },
project: { findMany: vi.fn().mockResolvedValue([]) },
};
service = new BrainService(prisma as unknown as PrismaService);
});
it("should throw BadRequestException for search term exceeding 500 characters", async () => {
const longTerm = "x".repeat(501);
await expect(service.search("workspace-id", longTerm)).rejects.toThrow(BadRequestException);
await expect(service.search("workspace-id", longTerm)).rejects.toThrow("500");
});
it("should accept search term at exactly 500 characters", async () => {
const maxTerm = "x".repeat(500);
await expect(service.search("workspace-id", maxTerm)).resolves.toBeDefined();
});
it("should clamp limit to max 100 when higher value provided", async () => {
await service.search("workspace-id", "test", 200);
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
});
it("should clamp limit to min 1 when negative value provided", async () => {
await service.search("workspace-id", "test", -5);
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }));
});
it("should clamp limit to min 1 when zero provided", async () => {
await service.search("workspace-id", "test", 0);
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }));
});
it("should pass through valid limit values unchanged", async () => {
await service.search("workspace-id", "test", 50);
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 50 }));
});
});
describe("BrainService.query defensive validation", () => {
let service: BrainService;
let prisma: {
task: { findMany: ReturnType<typeof vi.fn> };
event: { findMany: ReturnType<typeof vi.fn> };
project: { findMany: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
prisma = {
task: { findMany: vi.fn().mockResolvedValue([]) },
event: { findMany: vi.fn().mockResolvedValue([]) },
project: { findMany: vi.fn().mockResolvedValue([]) },
};
service = new BrainService(prisma as unknown as PrismaService);
});
it("should throw BadRequestException for search field exceeding 500 characters", async () => {
const longSearch = "y".repeat(501);
await expect(
service.query({ workspaceId: "workspace-id", search: longSearch })
).rejects.toThrow(BadRequestException);
});
it("should throw BadRequestException for query field exceeding 500 characters", async () => {
const longQuery = "z".repeat(501);
await expect(
service.query({ workspaceId: "workspace-id", query: longQuery })
).rejects.toThrow(BadRequestException);
});
it("should clamp limit to max 100 in query method", async () => {
await service.query({ workspaceId: "workspace-id", limit: 200 });
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
});
it("should clamp limit to min 1 in query method when negative", async () => {
await service.query({ workspaceId: "workspace-id", limit: -10 });
expect(prisma.task.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }));
});
it("should accept valid query and search within limits", async () => {
await expect(
service.query({
workspaceId: "workspace-id",
query: "test query",
search: "test search",
limit: 50,
})
).resolves.toBeDefined();
});
});
});