From 7ffdec7cdf8a5f039780c2566d96dfe28a6f0d78 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 9 Aug 2026 20:41:29 +0100 Subject: [PATCH] fix(api): coerce numeric query params, which the new global pipe rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing the global ValidationPipe in #199 made `@IsNumber()` run against query strings for the first time. Query values are always strings, so `GET /v1/payments?limit=2` started coming back 400 with "limit must be a number conforming to the specified constraints". Every paginated list endpoint using PaymentFiltersDto was affected — page, limit, minAmount and maxAmount alike. It broke precisely because validation started working: before the pipe, nothing ran, and the string went through untouched. `@Type(() => Number)` converts the four query fields before validation. Done on the DTO rather than by enabling `enableImplicitConversion` globally, because that flag also coerces request bodies — and a body field that quietly accepts "5" for 5 is not what you want on the endpoints that move money. Found by exercising the running API while auditing leftover worktree branches; the existing e2e suites never passed a numeric query parameter, so nothing caught it. They do now. Tests: 287 unit, 18 e2e (3 new). Co-Authored-By: Claude Opus 5 --- .../payments/dto/payment-filters.dto.ts | 18 +++++++++++++ apps/api/test/validation.e2e-spec.ts | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/apps/api/src/modules/payments/dto/payment-filters.dto.ts b/apps/api/src/modules/payments/dto/payment-filters.dto.ts index 9a80896..f2226d2 100644 --- a/apps/api/src/modules/payments/dto/payment-filters.dto.ts +++ b/apps/api/src/modules/payments/dto/payment-filters.dto.ts @@ -1,3 +1,4 @@ +import { Type } from 'class-transformer'; import { IsOptional, IsISO8601, @@ -20,6 +21,19 @@ const PAYMENT_STATUSES = [ 'FAILED', ] as const; +/** + * Query-string filters, so every value arrives as a string. + * + * The numeric fields carry `@Type(() => Number)` because of that: once a global + * ValidationPipe was installed, `?limit=2` reached `@IsNumber()` as `"2"` and + * was rejected with "limit must be a number conforming to the specified + * constraints". Before the pipe existed nothing validated, so the string sailed + * through — which is why this only broke when validation started working. + * + * Converted here rather than by turning on `enableImplicitConversion` globally: + * that would coerce body DTOs too, and an amount field that quietly accepts + * `"5"` for `5` is not what you want on the endpoints that move money. + */ export class PaymentFiltersDto { @IsIn(PAYMENT_STATUSES) @IsOptional() @@ -37,10 +51,12 @@ export class PaymentFiltersDto { @IsOptional() currency?: string; + @Type(() => Number) @IsNumber() @IsOptional() minAmount?: number; + @Type(() => Number) @IsNumber() @IsOptional() maxAmount?: number; @@ -49,10 +65,12 @@ export class PaymentFiltersDto { @IsOptional() search?: string; // search by ID, customer email + @Type(() => Number) @IsNumber() @IsOptional() page?: number; + @Type(() => Number) @IsNumber() @IsOptional() limit?: number; diff --git a/apps/api/test/validation.e2e-spec.ts b/apps/api/test/validation.e2e-spec.ts index 11797d9..4a8f60a 100644 --- a/apps/api/test/validation.e2e-spec.ts +++ b/apps/api/test/validation.e2e-spec.ts @@ -68,6 +68,31 @@ describe('Request validation (e2e)', () => { await patch({ settlementAsset: 'NOTANASSET' }).expect(400); }); + // Regression: installing the global pipe made `@IsNumber()` run against + // query strings for the first time, and `?limit=2` — a string at that point — + // started coming back 400. Every paginated list endpoint was affected. The + // fix is `@Type(() => Number)` on the query DTO, so these cases pin both + // halves: numbers get through, rubbish still does not. + describe('numeric query parameters', () => { + const list = (query: string) => + request(app.getHttpServer()) + .get(`/v1/payments${query}`) + .set('Authorization', `Bearer ${token}`); + + it('accepts a numeric limit and page from the query string', async () => { + await list('?limit=2').expect(200); + await list('?limit=2&page=1').expect(200); + }); + + it('still rejects a limit that is not a number', async () => { + await list('?limit=abc').expect(400); + }); + + it('still rejects a status outside the supported set', async () => { + await list('?status=NOPE').expect(400); + }); + }); + it('rejects a settlement chain that is not supported', async () => { await patch({ settlementChain: 'dogecoin' }).expect(400); });