fix(SEC-API-21): Add DTO validation for semantic/hybrid search body
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace inline type annotations with proper class-validator DTOs for the semantic and hybrid search endpoints. Adds SemanticSearchBodyDto, HybridSearchBodyDto (query: @IsString @MaxLength(500), status: @IsOptional @IsEnum(EntryStatus)), and SemanticSearchQueryDto (page/limit with @IsInt @Min/@Max validation). Includes 22 new tests covering DTO validation edge cases and controller integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,14 @@ export { EntryQueryDto } from "./entry-query.dto";
|
|||||||
export { CreateTagDto } from "./create-tag.dto";
|
export { CreateTagDto } from "./create-tag.dto";
|
||||||
export { UpdateTagDto } from "./update-tag.dto";
|
export { UpdateTagDto } from "./update-tag.dto";
|
||||||
export { RestoreVersionDto } from "./restore-version.dto";
|
export { RestoreVersionDto } from "./restore-version.dto";
|
||||||
export { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./search-query.dto";
|
export {
|
||||||
|
SearchQueryDto,
|
||||||
|
TagSearchDto,
|
||||||
|
RecentEntriesDto,
|
||||||
|
SemanticSearchBodyDto,
|
||||||
|
SemanticSearchQueryDto,
|
||||||
|
HybridSearchBodyDto,
|
||||||
|
} from "./search-query.dto";
|
||||||
export { GraphQueryDto, GraphFilterDto } from "./graph-query.dto";
|
export { GraphQueryDto, GraphFilterDto } from "./graph-query.dto";
|
||||||
export { ExportQueryDto, ExportFormat } from "./import-export.dto";
|
export { ExportQueryDto, ExportFormat } from "./import-export.dto";
|
||||||
export type { ImportResult, ImportResponseDto } from "./import-export.dto";
|
export type { ImportResult, ImportResponseDto } from "./import-export.dto";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum } from "class-validator";
|
import { IsOptional, IsString, IsInt, Min, Max, IsArray, IsEnum, MaxLength } from "class-validator";
|
||||||
import { Type, Transform } from "class-transformer";
|
import { Type, Transform } from "class-transformer";
|
||||||
import { EntryStatus } from "@prisma/client";
|
import { EntryStatus } from "@prisma/client";
|
||||||
|
|
||||||
@@ -75,3 +75,49 @@ export class RecentEntriesDto {
|
|||||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
status?: EntryStatus;
|
status?: EntryStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for semantic search request body
|
||||||
|
* Validates the query string and optional status filter
|
||||||
|
*/
|
||||||
|
export class SemanticSearchBodyDto {
|
||||||
|
@IsString({ message: "query must be a string" })
|
||||||
|
@MaxLength(500, { message: "query must not exceed 500 characters" })
|
||||||
|
query!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
|
status?: EntryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for semantic/hybrid search query parameters (pagination)
|
||||||
|
*/
|
||||||
|
export class SemanticSearchQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
@Min(1, { message: "page must be at least 1" })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "limit must be an integer" })
|
||||||
|
@Min(1, { message: "limit must be at least 1" })
|
||||||
|
@Max(100, { message: "limit must not exceed 100" })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for hybrid search request body
|
||||||
|
* Validates the query string and optional status filter
|
||||||
|
*/
|
||||||
|
export class HybridSearchBodyDto {
|
||||||
|
@IsString({ message: "query must be a string" })
|
||||||
|
@MaxLength(500, { message: "query must not exceed 500 characters" })
|
||||||
|
query!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
|
status?: EntryStatus;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { EntryStatus } from "@prisma/client";
|
import { EntryStatus } from "@prisma/client";
|
||||||
|
import { validate } from "class-validator";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
import { SearchController } from "./search.controller";
|
import { SearchController } from "./search.controller";
|
||||||
import { SearchService } from "./services/search.service";
|
import { SearchService } from "./services/search.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { SemanticSearchBodyDto, SemanticSearchQueryDto, HybridSearchBodyDto } from "./dto";
|
||||||
|
|
||||||
describe("SearchController", () => {
|
describe("SearchController", () => {
|
||||||
let controller: SearchController;
|
let controller: SearchController;
|
||||||
@@ -15,6 +18,8 @@ describe("SearchController", () => {
|
|||||||
search: vi.fn(),
|
search: vi.fn(),
|
||||||
searchByTags: vi.fn(),
|
searchByTags: vi.fn(),
|
||||||
recentEntries: vi.fn(),
|
recentEntries: vi.fn(),
|
||||||
|
semanticSearch: vi.fn(),
|
||||||
|
hybridSearch: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -217,4 +222,266 @@ describe("SearchController", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("semanticSearch", () => {
|
||||||
|
it("should call searchService.semanticSearch with correct parameters", async () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||||
|
query: "machine learning",
|
||||||
|
};
|
||||||
|
mockSearchService.semanticSearch.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const body = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "machine learning",
|
||||||
|
});
|
||||||
|
const query = plainToInstance(SemanticSearchQueryDto, {
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.semanticSearch(mockWorkspaceId, body, query);
|
||||||
|
|
||||||
|
expect(mockSearchService.semanticSearch).toHaveBeenCalledWith(
|
||||||
|
"machine learning",
|
||||||
|
mockWorkspaceId,
|
||||||
|
{
|
||||||
|
status: undefined,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass status filter from body to service", async () => {
|
||||||
|
mockSearchService.semanticSearch.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||||
|
query: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "test",
|
||||||
|
status: EntryStatus.PUBLISHED,
|
||||||
|
});
|
||||||
|
const query = plainToInstance(SemanticSearchQueryDto, {});
|
||||||
|
|
||||||
|
await controller.semanticSearch(mockWorkspaceId, body, query);
|
||||||
|
|
||||||
|
expect(mockSearchService.semanticSearch).toHaveBeenCalledWith("test", mockWorkspaceId, {
|
||||||
|
status: EntryStatus.PUBLISHED,
|
||||||
|
page: undefined,
|
||||||
|
limit: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hybridSearch", () => {
|
||||||
|
it("should call searchService.hybridSearch with correct parameters", async () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||||
|
query: "deep learning",
|
||||||
|
};
|
||||||
|
mockSearchService.hybridSearch.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const body = plainToInstance(HybridSearchBodyDto, {
|
||||||
|
query: "deep learning",
|
||||||
|
});
|
||||||
|
const query = plainToInstance(SemanticSearchQueryDto, {
|
||||||
|
page: 2,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.hybridSearch(mockWorkspaceId, body, query);
|
||||||
|
|
||||||
|
expect(mockSearchService.hybridSearch).toHaveBeenCalledWith(
|
||||||
|
"deep learning",
|
||||||
|
mockWorkspaceId,
|
||||||
|
{
|
||||||
|
status: undefined,
|
||||||
|
page: 2,
|
||||||
|
limit: 10,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass status filter from body to service", async () => {
|
||||||
|
mockSearchService.hybridSearch.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||||
|
query: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = plainToInstance(HybridSearchBodyDto, {
|
||||||
|
query: "test",
|
||||||
|
status: EntryStatus.DRAFT,
|
||||||
|
});
|
||||||
|
const query = plainToInstance(SemanticSearchQueryDto, {});
|
||||||
|
|
||||||
|
await controller.hybridSearch(mockWorkspaceId, body, query);
|
||||||
|
|
||||||
|
expect(mockSearchService.hybridSearch).toHaveBeenCalledWith("test", mockWorkspaceId, {
|
||||||
|
status: EntryStatus.DRAFT,
|
||||||
|
page: undefined,
|
||||||
|
limit: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SemanticSearchBodyDto validation", () => {
|
||||||
|
it("should pass with valid query", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, { query: "test search" });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass with query and valid status", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "test search",
|
||||||
|
status: EntryStatus.PUBLISHED,
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when query is missing", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const queryError = errors.find((e) => e.property === "query");
|
||||||
|
expect(queryError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when query is not a string", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, { query: 12345 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const queryError = errors.find((e) => e.property === "query");
|
||||||
|
expect(queryError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when query exceeds 500 characters", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "a".repeat(501),
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const queryError = errors.find((e) => e.property === "query");
|
||||||
|
expect(queryError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass when query is exactly 500 characters", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "a".repeat(500),
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with invalid status value", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchBodyDto, {
|
||||||
|
query: "test",
|
||||||
|
status: "INVALID_STATUS",
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const statusError = errors.find((e) => e.property === "status");
|
||||||
|
expect(statusError).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HybridSearchBodyDto validation", () => {
|
||||||
|
it("should pass with valid query", async () => {
|
||||||
|
const dto = plainToInstance(HybridSearchBodyDto, { query: "test search" });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass with query and valid status", async () => {
|
||||||
|
const dto = plainToInstance(HybridSearchBodyDto, {
|
||||||
|
query: "hybrid search",
|
||||||
|
status: EntryStatus.DRAFT,
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when query is missing", async () => {
|
||||||
|
const dto = plainToInstance(HybridSearchBodyDto, {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const queryError = errors.find((e) => e.property === "query");
|
||||||
|
expect(queryError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when query exceeds 500 characters", async () => {
|
||||||
|
const dto = plainToInstance(HybridSearchBodyDto, {
|
||||||
|
query: "a".repeat(501),
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const queryError = errors.find((e) => e.property === "query");
|
||||||
|
expect(queryError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with invalid status value", async () => {
|
||||||
|
const dto = plainToInstance(HybridSearchBodyDto, {
|
||||||
|
query: "test",
|
||||||
|
status: "NOT_A_STATUS",
|
||||||
|
});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const statusError = errors.find((e) => e.property === "status");
|
||||||
|
expect(statusError).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SemanticSearchQueryDto validation", () => {
|
||||||
|
it("should pass with valid page and limit", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, { page: 1, limit: 20 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass with no parameters (all optional)", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, {});
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when page is less than 1", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, { page: 0 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const pageError = errors.find((e) => e.property === "page");
|
||||||
|
expect(pageError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when limit exceeds 100", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, { limit: 101 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const limitError = errors.find((e) => e.property === "limit");
|
||||||
|
expect(limitError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail when limit is less than 1", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, { 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 fail when page is not an integer", async () => {
|
||||||
|
const dto = plainToInstance(SemanticSearchQueryDto, { page: 1.5 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const pageError = errors.find((e) => e.property === "page");
|
||||||
|
expect(pageError).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Controller, Get, Post, Body, Query, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Post, Body, Query, UseGuards } from "@nestjs/common";
|
||||||
import { SearchService, PaginatedSearchResults } from "./services/search.service";
|
import { SearchService, PaginatedSearchResults } from "./services/search.service";
|
||||||
import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto";
|
import {
|
||||||
|
SearchQueryDto,
|
||||||
|
TagSearchDto,
|
||||||
|
RecentEntriesDto,
|
||||||
|
SemanticSearchBodyDto,
|
||||||
|
SemanticSearchQueryDto,
|
||||||
|
HybridSearchBodyDto,
|
||||||
|
} from "./dto";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
import { EntryStatus } from "@prisma/client";
|
|
||||||
import type { PaginatedEntries, KnowledgeEntryWithTags } from "./entities/knowledge-entry.entity";
|
import type { PaginatedEntries, KnowledgeEntryWithTags } from "./entities/knowledge-entry.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,14 +118,13 @@ export class SearchController {
|
|||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
async semanticSearch(
|
async semanticSearch(
|
||||||
@Workspace() workspaceId: string,
|
@Workspace() workspaceId: string,
|
||||||
@Body() body: { query: string; status?: EntryStatus },
|
@Body() body: SemanticSearchBodyDto,
|
||||||
@Query("page") page?: number,
|
@Query() query: SemanticSearchQueryDto
|
||||||
@Query("limit") limit?: number
|
|
||||||
): Promise<PaginatedSearchResults> {
|
): Promise<PaginatedSearchResults> {
|
||||||
return this.searchService.semanticSearch(body.query, workspaceId, {
|
return this.searchService.semanticSearch(body.query, workspaceId, {
|
||||||
status: body.status,
|
status: body.status,
|
||||||
page,
|
page: query.page,
|
||||||
limit,
|
limit: query.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +143,13 @@ export class SearchController {
|
|||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
async hybridSearch(
|
async hybridSearch(
|
||||||
@Workspace() workspaceId: string,
|
@Workspace() workspaceId: string,
|
||||||
@Body() body: { query: string; status?: EntryStatus },
|
@Body() body: HybridSearchBodyDto,
|
||||||
@Query("page") page?: number,
|
@Query() query: SemanticSearchQueryDto
|
||||||
@Query("limit") limit?: number
|
|
||||||
): Promise<PaginatedSearchResults> {
|
): Promise<PaginatedSearchResults> {
|
||||||
return this.searchService.hybridSearch(body.query, workspaceId, {
|
return this.searchService.hybridSearch(body.query, workspaceId, {
|
||||||
status: body.status,
|
status: body.status,
|
||||||
page,
|
page: query.page,
|
||||||
limit,
|
limit: query.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user