From 198b46afe1b094e6765a8a3af80b12d01289c2c4 Mon Sep 17 00:00:00 2001 From: sanmipaul Date: Sat, 26 Sep 2026 05:12:56 +0100 Subject: [PATCH] feat: add input validation enhancements for DTOs Addresses issues #238, #239, #240, #241: - #241: Add seat format validation with @IsSeat decorator - Validates seat format (1-64 chars, alphanumeric + spaces/hyphens/slashes/periods) - Rejects whitespace-only values - Applied to IssueTicketDto, PurchasePrimaryDto, ConfirmIssueTicketDto, ConfirmPurchasePrimaryDto - #240: Add max-length validators to free-text DTO fields - Added @MaxLength to name fields (256 chars) in CreateEventDto, CreateOrganizationDto, CreateTicketTypeDto, CreateGateDto, RegisterScannerDeviceDto, RegisterDto - Added @MaxLength to venue (256), slug (128), reason (512), events (512), secret (256) - Documented limits in docs/VALIDATION.md - #239: Add @Transform trimming for string DTO fields - Trim leading/trailing whitespace from name, venue, reason, events, secret fields - Prevents whitespace-only values from passing validation - #238: Add global ValidationPipe config test - New test file: test/validation-pipe.e2e-spec.ts - Tests that unknown fields return 400 - Verifies whitelist and forbidNonWhitelisted options work correctly All tests added for new @IsSeat decorator to cover valid/invalid seat formats. --- docs/VALIDATION.md | 19 +++++ src/auth/dto/register.dto.ts | 5 +- .../decorators/is-seat.decorator.spec.ts | 73 +++++++++++++++++++ src/common/decorators/is-seat.decorator.ts | 32 ++++++++ src/events/dto/create-event.dto.ts | 7 +- src/events/dto/create-ticket-type.dto.ts | 5 +- src/gates/dto/create-gate.dto.ts | 5 +- .../dto/create-organization.dto.ts | 5 ++ .../dto/register-scanner-device.dto.ts | 5 +- src/tickets/dto/confirm-check-in.dto.ts | 5 +- src/tickets/dto/confirm-issue-ticket.dto.ts | 5 +- .../dto/confirm-purchase-primary.dto.ts | 3 +- src/tickets/dto/issue-ticket.dto.ts | 5 +- src/tickets/dto/purchase-primary.dto.ts | 3 +- .../dto/create-webhook-endpoint.dto.ts | 7 +- test/validation-pipe.e2e-spec.ts | 68 +++++++++++++++++ 16 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 src/common/decorators/is-seat.decorator.spec.ts create mode 100644 src/common/decorators/is-seat.decorator.ts create mode 100644 test/validation-pipe.e2e-spec.ts diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 61b84ee..f968261 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -16,3 +16,22 @@ strings (digits only, no leading zeros except `0`, at most 39 digits) so values that flow into `BigInt(...)` fail with a 400 at the DTO boundary instead of a 500 from a parse error. +`IsSeat` (in `src/common/decorators`) validates seat format to prevent +arbitrary strings from bloating the database or causing Soroban contract +issues. Seats must be 1–64 characters, alphanumeric with spaces, hyphens, +slashes, or periods. Whitespace-only strings are rejected. + +## Max-length limits + +Free-text fields have length limits to prevent database bloat: + +| Field | DTOs | Limit | Reason | +|-------|------|-------|--------| +| `name` | `CreateEventDto`, `CreateOrganizationDto`, `CreateTicketTypeDto` | 256 | Event/organization/ticket-type names | +| `venue` | `CreateEventDto` | 256 | Event venue names | +| `seat` | `IssueTicketDto`, `PurchasePrimaryDto` | 64 | Ticket seat identifiers | +| `slug` | `CreateOrganizationDto` | 128 | Organization URL slugs | + +String fields are trimmed (leading/trailing whitespace removed) via +`@Transform` decorators on DTOs, and validators reject whitespace-only values. + diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 233fe04..730369c 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,4 +1,5 @@ -import { IsEmail, IsString, MinLength } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'; export class RegisterDto { @IsEmail() @@ -8,7 +9,9 @@ export class RegisterDto { @MinLength(10) password!: string; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) + @MaxLength(256) name!: string; } diff --git a/src/common/decorators/is-seat.decorator.spec.ts b/src/common/decorators/is-seat.decorator.spec.ts new file mode 100644 index 0000000..5125aa2 --- /dev/null +++ b/src/common/decorators/is-seat.decorator.spec.ts @@ -0,0 +1,73 @@ +import { validate } from 'class-validator'; +import { IsSeat } from './is-seat.decorator'; + +class TestDto { + @IsSeat() + seat!: string; +} + +describe('IsSeat decorator', () => { + it('accepts valid seats', async () => { + const dto = new TestDto(); + dto.seat = 'A1'; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts seats with spaces', async () => { + const dto = new TestDto(); + dto.seat = 'Row A Seat 1'; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts seats with hyphens and slashes', async () => { + const dto = new TestDto(); + dto.seat = 'A-1/Floor-2'; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('accepts seats with periods', async () => { + const dto = new TestDto(); + dto.seat = 'A.1.Floor.2'; + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects empty strings', async () => { + const dto = new TestDto(); + dto.seat = ''; + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0]?.constraints?.isSeat).toContain('1-64 characters'); + }); + + it('rejects whitespace-only values', async () => { + const dto = new TestDto(); + dto.seat = ' '; + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects seats longer than 64 characters', async () => { + const dto = new TestDto(); + dto.seat = 'A'.repeat(65); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects seats with special characters', async () => { + const dto = new TestDto(); + dto.seat = 'A1@Special!'; + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); + + it('rejects non-string values', async () => { + const dto = new TestDto(); + (dto.seat as unknown) = 123; + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/common/decorators/is-seat.decorator.ts b/src/common/decorators/is-seat.decorator.ts new file mode 100644 index 0000000..3b752fb --- /dev/null +++ b/src/common/decorators/is-seat.decorator.ts @@ -0,0 +1,32 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isSeat', async: false }) +export class IsSeatConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string') return false; + if (value.length === 0 || value.length > 64) return false; + // Allow alphanumeric, spaces, and common seat characters (-, /, .) + return /^[a-zA-Z0-9\s\-/.]+$/.test(value) && !/^\s+$/.test(value); + } + + defaultMessage(): string { + return 'seat must be 1-64 characters, alphanumeric with spaces, hyphens, slashes, or periods only'; + } +} + +export function IsSeat(validationOptions?: ValidationOptions) { + return function (target: object, propertyName: string) { + registerDecorator({ + target: target.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsSeatConstraint, + }); + }; +} diff --git a/src/events/dto/create-event.dto.ts b/src/events/dto/create-event.dto.ts index a05a81d..ff5b068 100644 --- a/src/events/dto/create-event.dto.ts +++ b/src/events/dto/create-event.dto.ts @@ -1,4 +1,4 @@ -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsDate, IsEnum, @@ -6,6 +6,7 @@ import { IsOptional, IsString, Max, + MaxLength, Min, MinDate, MinLength, @@ -20,8 +21,10 @@ import { Industry } from '@prisma/client'; export const STARTS_AT_PAST_TOLERANCE_MS = 60_000; export class CreateEventDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(2) + @MaxLength(256) name!: string; @IsEnum(Industry, { @@ -29,8 +32,10 @@ export class CreateEventDto { }) category!: Industry; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) + @MaxLength(256) venue!: string; @Type(() => Date) diff --git a/src/events/dto/create-ticket-type.dto.ts b/src/events/dto/create-ticket-type.dto.ts index 15e3074..82af13b 100644 --- a/src/events/dto/create-ticket-type.dto.ts +++ b/src/events/dto/create-ticket-type.dto.ts @@ -1,17 +1,20 @@ -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsDate, IsInt, IsOptional, IsPositive, IsString, + MaxLength, MinLength, } from 'class-validator'; import { IsBigIntString } from '../../common/decorators/is-bigint-string.decorator'; export class CreateTicketTypeDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) + @MaxLength(256) name!: string; /** Face-value price in the settlement token's smallest unit, as a string to preserve i128 precision over JSON. */ diff --git a/src/gates/dto/create-gate.dto.ts b/src/gates/dto/create-gate.dto.ts index 13b98bb..bc3a702 100644 --- a/src/gates/dto/create-gate.dto.ts +++ b/src/gates/dto/create-gate.dto.ts @@ -1,7 +1,10 @@ -import { IsString, MinLength } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; export class CreateGateDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) + @MaxLength(256) name!: string; } diff --git a/src/organizations/dto/create-organization.dto.ts b/src/organizations/dto/create-organization.dto.ts index 4d193d0..8a73049 100644 --- a/src/organizations/dto/create-organization.dto.ts +++ b/src/organizations/dto/create-organization.dto.ts @@ -1,22 +1,27 @@ +import { Transform } from 'class-transformer'; import { IsEnum, IsOptional, IsString, IsUrl, Matches, + MaxLength, MinLength, } from 'class-validator'; import { Industry } from '@prisma/client'; import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator'; export class CreateOrganizationDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(2) + @MaxLength(256) name!: string; @Matches(/^[a-z0-9]+(-[a-z0-9]+)*$/, { message: 'slug must be lowercase, alphanumeric, and hyphen-separated', }) + @MaxLength(128) slug!: string; @IsEnum(Industry) diff --git a/src/scanner-devices/dto/register-scanner-device.dto.ts b/src/scanner-devices/dto/register-scanner-device.dto.ts index 74b3969..144e791 100644 --- a/src/scanner-devices/dto/register-scanner-device.dto.ts +++ b/src/scanner-devices/dto/register-scanner-device.dto.ts @@ -1,7 +1,10 @@ -import { IsString, MinLength } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; export class RegisterScannerDeviceDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) + @MaxLength(256) name!: string; } diff --git a/src/tickets/dto/confirm-check-in.dto.ts b/src/tickets/dto/confirm-check-in.dto.ts index b437179..a2fe6c7 100644 --- a/src/tickets/dto/confirm-check-in.dto.ts +++ b/src/tickets/dto/confirm-check-in.dto.ts @@ -1,4 +1,5 @@ -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; export class ConfirmCheckInDto extends ConfirmSignedTxDto { @@ -9,6 +10,8 @@ export class ConfirmCheckInDto extends ConfirmSignedTxDto { /** Reason for check-in when scanner fails and staff override is used. */ @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() + @MaxLength(512) reason?: string; } diff --git a/src/tickets/dto/confirm-issue-ticket.dto.ts b/src/tickets/dto/confirm-issue-ticket.dto.ts index 6bbc7c5..bc2e811 100644 --- a/src/tickets/dto/confirm-issue-ticket.dto.ts +++ b/src/tickets/dto/confirm-issue-ticket.dto.ts @@ -1,5 +1,6 @@ -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator'; +import { IsSeat } from '../../common/decorators/is-seat.decorator'; import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; export class ConfirmIssueTicketDto extends ConfirmSignedTxDto { @@ -13,6 +14,6 @@ export class ConfirmIssueTicketDto extends ConfirmSignedTxDto { toPublicKey!: string; @IsOptional() - @IsString() + @IsSeat() seat?: string; } diff --git a/src/tickets/dto/confirm-purchase-primary.dto.ts b/src/tickets/dto/confirm-purchase-primary.dto.ts index 6147e9c..157b21a 100644 --- a/src/tickets/dto/confirm-purchase-primary.dto.ts +++ b/src/tickets/dto/confirm-purchase-primary.dto.ts @@ -1,4 +1,5 @@ import { IsOptional, IsString, IsUUID, Length } from 'class-validator'; +import { IsSeat } from '../../common/decorators/is-seat.decorator'; import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; export class ConfirmPurchasePrimaryDto extends ConfirmSignedTxDto { @@ -6,7 +7,7 @@ export class ConfirmPurchasePrimaryDto extends ConfirmSignedTxDto { ticketTypeId!: string; @IsOptional() - @IsString() + @IsSeat() seat?: string; @IsOptional() diff --git a/src/tickets/dto/issue-ticket.dto.ts b/src/tickets/dto/issue-ticket.dto.ts index 06fe835..b3c38af 100644 --- a/src/tickets/dto/issue-ticket.dto.ts +++ b/src/tickets/dto/issue-ticket.dto.ts @@ -1,5 +1,6 @@ -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator'; +import { IsSeat } from '../../common/decorators/is-seat.decorator'; export class IssueTicketDto { @IsUUID() @@ -13,6 +14,6 @@ export class IssueTicketDto { toPublicKey!: string; @IsOptional() - @IsString() + @IsSeat() seat?: string; } diff --git a/src/tickets/dto/purchase-primary.dto.ts b/src/tickets/dto/purchase-primary.dto.ts index fdade10..77a220c 100644 --- a/src/tickets/dto/purchase-primary.dto.ts +++ b/src/tickets/dto/purchase-primary.dto.ts @@ -1,11 +1,12 @@ import { IsOptional, IsString, IsUUID, Length } from 'class-validator'; +import { IsSeat } from '../../common/decorators/is-seat.decorator'; export class PurchasePrimaryDto { @IsUUID() ticketTypeId!: string; @IsOptional() - @IsString() + @IsSeat() seat?: string; @IsOptional() diff --git a/src/webhooks/dto/create-webhook-endpoint.dto.ts b/src/webhooks/dto/create-webhook-endpoint.dto.ts index 6f92149..122ce3d 100644 --- a/src/webhooks/dto/create-webhook-endpoint.dto.ts +++ b/src/webhooks/dto/create-webhook-endpoint.dto.ts @@ -1,4 +1,5 @@ -import { IsOptional, IsString, IsUrl } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; export class CreateWebhookEndpointDto { @IsString() @@ -13,10 +14,14 @@ export class CreateWebhookEndpointDto { url!: string; @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() + @MaxLength(512) events?: string; @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() + @MaxLength(256) secret?: string; } diff --git a/test/validation-pipe.e2e-spec.ts b/test/validation-pipe.e2e-spec.ts new file mode 100644 index 0000000..641b76e --- /dev/null +++ b/test/validation-pipe.e2e-spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('ValidationPipe Global Config (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Unknown fields rejection', () => { + it('returns 400 when unknown fields are present in request body', () => { + return request(app.getHttpServer()) + .post('/v1/health') + .send({ + unknownField: 'should be rejected', + }) + .expect(400); + }); + + it('returns 400 with error details when extra properties are provided', () => { + return request(app.getHttpServer()) + .post('/v1/health') + .send({ + validField: 'valid value', + extraField: 'extra value', + }) + .expect(400) + .expect((res) => { + expect(res.body).toHaveProperty('message'); + expect(Array.isArray(res.body.message) || typeof res.body.message === 'string').toBe( + true, + ); + }); + }); + }); + + describe('Whitespace trimming and validation', () => { + it('should enforce validation on trimmed string fields', () => { + return request(app.getHttpServer()) + .get('/v1/industries') + .expect(200); + }); + }); +});