diff --git a/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts index 88d5204..3de41fd 100644 --- a/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts +++ b/apps/api/src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts @@ -2,6 +2,7 @@ import type { EventBus } from '@nestjs/cqrs'; import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command'; import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler'; +import { SanitizeHtmlService } from '../services/sanitize-html.service'; describe('CreateInquiryHandler', () => { let handler: CreateInquiryHandler; @@ -10,6 +11,7 @@ describe('CreateInquiryHandler', () => { let mockPrisma: { listing: { findUnique: ReturnType }; }; + let sanitizer: SanitizeHtmlService; beforeEach(() => { mockInquiryRepo = { @@ -29,11 +31,14 @@ describe('CreateInquiryHandler', () => { const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + sanitizer = new SanitizeHtmlService(); + handler = new CreateInquiryHandler( mockInquiryRepo as any, mockEventBus as unknown as EventBus, mockPrisma as any, mockLogger as any, + sanitizer, ); }); @@ -95,4 +100,48 @@ describe('CreateInquiryHandler', () => { }), ); }); + + it('sanitizes ', + null, + ); + + await handler.execute(command); + + const savedInquiry = mockInquiryRepo.save.mock.calls[0][0]; + const persisted: string = savedInquiry.message; + + expect(persisted).toContain('

Chào anh

'); + expect(persisted).not.toContain(' { + mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' }); + mockInquiryRepo.save.mockResolvedValue(undefined); + + const command = new CreateInquiryCommand( + 'user-1', + 'listing-1', + 'okbad', + null, + ); + + await handler.execute(command); + + const persisted: string = mockInquiryRepo.save.mock.calls[0][0].message; + + expect(persisted).toContain('href="https://example.com"'); + expect(persisted).toContain('rel="noopener noreferrer nofollow"'); + expect(persisted).toContain('target="_blank"'); + expect(persisted).not.toContain('javascript:'); + }); }); diff --git a/apps/api/src/modules/inquiries/application/__tests__/sanitize-html.service.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/sanitize-html.service.spec.ts new file mode 100644 index 0000000..d09bfc1 --- /dev/null +++ b/apps/api/src/modules/inquiries/application/__tests__/sanitize-html.service.spec.ts @@ -0,0 +1,47 @@ +import { SanitizeHtmlService } from '../services/sanitize-html.service'; + +describe('SanitizeHtmlService', () => { + const sanitizer = new SanitizeHtmlService(); + + it('strips '); + expect(out).toBe('

Hi

'); + }); + + it('drops inline event handlers like onerror', () => { + const out = sanitizer.sanitizeInquiryMessage(''); + expect(out).not.toContain('onerror'); + expect(out).not.toContain(' { + const out = sanitizer.sanitizeInquiryMessage('bold italic

para

'); + expect(out).toContain('bold'); + expect(out).toContain('italic'); + expect(out).toContain('
'); + expect(out).toContain('

para

'); + }); + + it('forces rel and target on ', () => { + const out = sanitizer.sanitizeInquiryMessage('x'); + expect(out).toContain('href="https://example.com"'); + expect(out).toContain('rel="noopener noreferrer nofollow"'); + expect(out).toContain('target="_blank"'); + }); + + it('drops javascript: scheme links', () => { + const out = sanitizer.sanitizeInquiryMessage('x'); + expect(out).not.toContain('javascript:'); + }); + + it('returns empty string for nullish input', () => { + expect(sanitizer.sanitizeInquiryMessage(null)).toBe(''); + expect(sanitizer.sanitizeInquiryMessage(undefined)).toBe(''); + expect(sanitizer.sanitizeInquiryMessage('')).toBe(''); + }); + + it('preserves plain Vietnamese text', () => { + const text = 'Tôi muốn xem nhà vào cuối tuần này, anh sắp xếp giúp ạ.'; + expect(sanitizer.sanitizeInquiryMessage(text)).toBe(text); + }); +}); diff --git a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts index 4bf92c1..645d103 100644 --- a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts +++ b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts @@ -4,6 +4,7 @@ import { createId } from '@paralleldrive/cuid2'; import { DomainException, NotFoundException, PrismaService, LoggerService } from '@modules/shared'; import { InquiryEntity } from '../../../domain/entities/inquiry.entity'; import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; +import { SanitizeHtmlService } from '../../services/sanitize-html.service'; import { CreateInquiryCommand } from './create-inquiry.command'; export interface CreateInquiryResult { @@ -19,6 +20,7 @@ export class CreateInquiryHandler implements ICommandHandler { @@ -33,11 +35,12 @@ export class CreateInquiryHandler implements ICommandHandler, ,
,

, (links forced to rel=noopener noreferrer nofollow, + * target=_blank, http(s) only). + * + * Everything else (including