Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/api/src/modules/payments/dto/payment-filters.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Type } from 'class-transformer';
import {
IsOptional,
IsISO8601,
Expand All @@ -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()
Expand All @@ -37,10 +51,12 @@ export class PaymentFiltersDto {
@IsOptional()
currency?: string;

@Type(() => Number)
@IsNumber()
@IsOptional()
minAmount?: number;

@Type(() => Number)
@IsNumber()
@IsOptional()
maxAmount?: number;
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions apps/api/test/validation.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading