diff --git a/apps/api/src/knowledge/dto/search-query.dto.spec.ts b/apps/api/src/knowledge/dto/search-query.dto.spec.ts new file mode 100644 index 0000000..c165659 --- /dev/null +++ b/apps/api/src/knowledge/dto/search-query.dto.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { SearchQueryDto } from "./search-query.dto"; + +/** + * Validation tests for SearchQueryDto + * + * Verifies that the full-text knowledge search endpoint + * enforces input length limits to prevent abuse. + */ +describe("SearchQueryDto - Input Validation", () => { + it("should pass validation with a valid query string", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: "search term", + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should pass validation with a query at exactly 500 characters", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: "a".repeat(500), + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should reject a query exceeding 500 characters", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: "a".repeat(501), + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const qError = errors.find((e) => e.property === "q"); + expect(qError).toBeDefined(); + expect(qError!.constraints).toHaveProperty("maxLength"); + expect(qError!.constraints!.maxLength).toContain("500"); + }); + + it("should reject a missing q field", async () => { + const dto = plainToInstance(SearchQueryDto, {}); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const qError = errors.find((e) => e.property === "q"); + expect(qError).toBeDefined(); + }); + + it("should reject a non-string q field", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: 12345, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const qError = errors.find((e) => e.property === "q"); + expect(qError).toBeDefined(); + }); + + it("should pass validation with optional fields included", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: "search term", + page: 1, + limit: 10, + }); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it("should reject limit exceeding 100", async () => { + const dto = plainToInstance(SearchQueryDto, { + q: "search term", + limit: 101, + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + const limitError = errors.find((e) => e.property === "limit"); + expect(limitError).toBeDefined(); + }); +}); diff --git a/apps/api/src/knowledge/dto/search-query.dto.ts b/apps/api/src/knowledge/dto/search-query.dto.ts index d7428a7..50a6c31 100644 --- a/apps/api/src/knowledge/dto/search-query.dto.ts +++ b/apps/api/src/knowledge/dto/search-query.dto.ts @@ -7,6 +7,7 @@ import { EntryStatus } from "@prisma/client"; */ export class SearchQueryDto { @IsString({ message: "q (query) must be a string" }) + @MaxLength(500, { message: "q must not exceed 500 characters" }) q!: string; @IsOptional() diff --git a/apps/api/src/knowledge/utils/markdown.ts b/apps/api/src/knowledge/utils/markdown.ts index 09e5cdb..b648e62 100644 --- a/apps/api/src/knowledge/utils/markdown.ts +++ b/apps/api/src/knowledge/utils/markdown.ts @@ -1,9 +1,12 @@ +import { Logger } from "@nestjs/common"; import { marked } from "marked"; import { gfmHeadingId } from "marked-gfm-heading-id"; import { markedHighlight } from "marked-highlight"; import hljs from "highlight.js"; import sanitizeHtml from "sanitize-html"; +const logger = new Logger("MarkdownRenderer"); + /** * Configure marked with GFM, syntax highlighting, and security features */ @@ -199,8 +202,8 @@ export async function renderMarkdown(markdown: string): Promise { return safeHtml; } catch (error) { // Log error but don't expose internal details - console.error("Markdown rendering error:", error); - throw new Error("Failed to render markdown content"); + logger.error("Markdown rendering error:", error); + throw new Error("Failed to render markdown content", { cause: error }); } } @@ -225,8 +228,8 @@ export function renderMarkdownSync(markdown: string): string { return safeHtml; } catch (error) { - console.error("Markdown rendering error:", error); - throw new Error("Failed to render markdown content"); + logger.error("Markdown rendering error:", error); + throw new Error("Failed to render markdown content", { cause: error }); } }