diff --git a/BackendAcademy/src/app.controller.spec.ts b/BackendAcademy/src/app.controller.spec.ts index 4f5dfe55e..214078d41 100644 --- a/BackendAcademy/src/app.controller.spec.ts +++ b/BackendAcademy/src/app.controller.spec.ts @@ -1,7 +1,25 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ArgumentMetadata, BadRequestException, ValidationPipe } from '@nestjs/common'; +import { IsNotEmpty, IsString } from 'class-validator'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +const validationPipeOptions = { + whitelist: true, + transform: true, + forbidNonWhitelisted: true, +}; + +class SampleCourseDto { + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; +} + describe('AppController', () => { let appController: AppController; @@ -19,4 +37,69 @@ describe('AppController', () => { expect(appController.getHello()).toBe('RustAcademy BackendAcademy API'); }); }); + + describe('Global Request Validation (BA-120)', () => { + let pipe: ValidationPipe; + + beforeEach(() => { + pipe = new ValidationPipe(validationPipeOptions); + }); + + it('should reject unknown DTO properties without stripping or silently accepting them', async () => { + const metadata: ArgumentMetadata = { + type: 'body', + metatype: SampleCourseDto, + data: '', + }; + + const payloadWithExtraneousField = { + title: 'Soroban 101', + description: 'Introduction to Soroban smart contracts', + maliciousParam: 'unexpected', + isAdmin: true, + }; + + await expect( + pipe.transform(payloadWithExtraneousField, metadata), + ).rejects.toThrow(BadRequestException); + }); + + it('should include property name in validation error response when unknown fields are present', async () => { + const metadata: ArgumentMetadata = { + type: 'body', + metatype: SampleCourseDto, + data: '', + }; + + try { + await pipe.transform({ title: 'Rust', description: 'Basics', extraField: 123 }, metadata); + fail('Expected ValidationPipe to reject unknown property'); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + const res = (err as BadRequestException).getResponse() as any; + expect(res.message).toEqual( + expect.arrayContaining([expect.stringContaining('property extraField should not exist')]), + ); + } + }); + + it('should successfully accept valid payloads conforming to target DTO', async () => { + const metadata: ArgumentMetadata = { + type: 'body', + metatype: SampleCourseDto, + data: '', + }; + + const validPayload = { + title: 'Rust Basics', + description: 'Learn memory safety without garbage collection', + }; + + const result = await pipe.transform(validPayload, metadata); + expect(result).toBeInstanceOf(SampleCourseDto); + expect(result.title).toBe('Rust Basics'); + expect(result.description).toBe('Learn memory safety without garbage collection'); + }); + }); }); + diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index 498dc78e1..ed3e9e2fa 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; +import { Module, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_PIPE } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -57,6 +57,16 @@ import { CertificatesModule } from './certificates/certificates.module'; // token), so the rate limiting configured above is actually // enforced on every request rather than just being available. { provide: APP_GUARD, useClass: ThrottlerGuard }, + // Registers global strict validation pipe: rejects unknown DTO properties + // without stripping or silently accepting them. + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + }, ], }) export class AppModule {} \ No newline at end of file diff --git a/BackendAcademy/src/main.ts b/BackendAcademy/src/main.ts index 71c010075..3ed29fb35 100644 --- a/BackendAcademy/src/main.ts +++ b/BackendAcademy/src/main.ts @@ -6,6 +6,12 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; +export const validationPipeOptions = { + whitelist: true, + transform: true, + forbidNonWhitelisted: true, +}; + // Application entry point: creates the Nest app, applies global // middleware/pipes, optionally sets up Swagger docs, and starts // listening on the configured port. @@ -25,18 +31,13 @@ async function bootstrap() { // liveness/readiness/status checks remain reachable at their bare // paths for monitoring tools that expect them un-prefixed). app.setGlobalPrefix('api', { exclude: ['health'] }); - // Global validation pipe applied to all incoming request bodies/params: // - whitelist: strips properties not defined on the target DTO // - transform: converts payloads into instances of their DTO classes // - forbidNonWhitelisted: rejects requests containing any property // not defined on the DTO (rather than silently dropping them) app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - transform: true, - forbidNonWhitelisted: true, - }), + new ValidationPipe(validationPipeOptions), ); const configService = app.get(ConfigService);