All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
235 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|