From c5b79ecdd0072c1859469c3528542c8eeeff35a7 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Fri, 21 Aug 2026 20:18:10 +0000 Subject: [PATCH] fix(dto,escrow): validate future deadline, deduplicate split validation, and separate integration specs (#171, #167, #177) --- package.json | 4 ++ src/bounties/dto/create-bounty.dto.ts | 2 + .../validators/future-date.validator.spec.ts | 50 +++++++++++++++++++ .../validators/future-date.validator.ts | 35 +++++++++++++ src/escrow/escrow.service.ts | 16 +----- .../dto/create-milestone.dto.spec.ts | 30 +++++++++++ src/milestones/dto/create-milestone.dto.ts | 2 + src/teams/team-split.util.ts | 15 ++++-- test/users.e2e-spec.ts | 8 +-- 9 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 src/common/validators/future-date.validator.spec.ts create mode 100644 src/common/validators/future-date.validator.ts create mode 100644 src/milestones/dto/create-milestone.dto.spec.ts diff --git a/package.json b/package.json index e65c3bc..33514eb 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:integration": "jest --testRegex='.*\\.integration\\.spec\\.ts$'", "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", "migration:generate": "npm run typeorm -- migration:generate", "migration:create": "typeorm-ts-node-commonjs migration:create", @@ -93,6 +94,9 @@ ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": [ + ".*\\.integration\\.spec\\.ts$" + ], "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/src/bounties/dto/create-bounty.dto.ts b/src/bounties/dto/create-bounty.dto.ts index 24d5276..ee2aaea 100644 --- a/src/bounties/dto/create-bounty.dto.ts +++ b/src/bounties/dto/create-bounty.dto.ts @@ -5,6 +5,7 @@ import { IsMoneyAmount, IsSupportedEscrowAsset, } from '../../common/validators/money.validator'; +import { IsFutureDate } from '../../common/validators/future-date.validator'; export class CreateBountyDto { @ApiProperty({ @@ -35,5 +36,6 @@ export class CreateBountyDto { @ApiProperty({ required: false }) @IsOptional() @IsISO8601() + @IsFutureDate() deadline?: string; } diff --git a/src/common/validators/future-date.validator.spec.ts b/src/common/validators/future-date.validator.spec.ts new file mode 100644 index 0000000..9e87215 --- /dev/null +++ b/src/common/validators/future-date.validator.spec.ts @@ -0,0 +1,50 @@ +import { isFutureDate, IsFutureDate } from './future-date.validator'; +import { validate } from 'class-validator'; + +class TestDto { + @IsFutureDate() + deadline?: string; +} + +describe('future-date.validator', () => { + describe('isFutureDate helper', () => { + it('returns true for a future date string', () => { + const future = new Date(Date.now() + 100_000).toISOString(); + expect(isFutureDate(future)).toBe(true); + }); + + it('returns false for a past date string', () => { + const past = new Date(Date.now() - 100_000).toISOString(); + expect(isFutureDate(past)).toBe(false); + }); + + it('returns false for invalid date strings or non-strings', () => { + expect(isFutureDate('not-a-date')).toBe(false); + expect(isFutureDate(12345)).toBe(false); + expect(isFutureDate({})).toBe(false); + }); + }); + + describe('@IsFutureDate decorator', () => { + it('passes when deadline is undefined/null (optional)', async () => { + const dto = new TestDto(); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('passes when deadline is in the future', async () => { + const dto = new TestDto(); + dto.deadline = new Date(Date.now() + 86_400_000).toISOString(); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('fails when deadline is in the past', async () => { + const dto = new TestDto(); + dto.deadline = new Date(Date.now() - 86_400_000).toISOString(); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints?.isFutureDate).toBeDefined(); + }); + }); +}); diff --git a/src/common/validators/future-date.validator.ts b/src/common/validators/future-date.validator.ts new file mode 100644 index 0000000..234f9cd --- /dev/null +++ b/src/common/validators/future-date.validator.ts @@ -0,0 +1,35 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +/** + * Validates that an ISO-8601 string or Date object represents a timestamp strictly in the future. + */ +export function isFutureDate(value: unknown): boolean { + if (typeof value !== 'string' && !(value instanceof Date)) return false; + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) return false; + return date.getTime() > Date.now(); +} + +export function IsFutureDate(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isFutureDate', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + if (value === undefined || value === null) return true; + return isFutureDate(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid ISO-8601 date string in the future`; + }, + }, + }); + }; +} diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 9986857..d083839 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -16,6 +16,7 @@ import { } from '../common/validators/money.validator'; import { SorobanClientService } from './soroban-client.service'; import { apportionBasisPoints, splitStroops } from './split-math.util'; +import { validateSplitPercentages } from '../teams/team-split.util'; export interface FundEscrowInput { amount: string; @@ -282,20 +283,7 @@ export class EscrowService { /** Validates that split percentages sum to 100.00, within floating point tolerance. */ assertValidSplits(recipients: SplitRecipient[]): void { - if (recipients.length === 0) { - throw new BadRequestException( - 'At least one recipient is required for a split release', - ); - } - const total = recipients.reduce((sum, r) => sum + r.percentage, 0); - if (Math.abs(total - 100) > 0.01) { - throw new BadRequestException( - `Split percentages must sum to 100, got ${total.toFixed(2)}`, - ); - } - if (recipients.some((r) => r.percentage <= 0)) { - throw new BadRequestException('Split percentages must be positive'); - } + validateSplitPercentages(recipients, 'Split'); } /** diff --git a/src/milestones/dto/create-milestone.dto.spec.ts b/src/milestones/dto/create-milestone.dto.spec.ts new file mode 100644 index 0000000..90998e3 --- /dev/null +++ b/src/milestones/dto/create-milestone.dto.spec.ts @@ -0,0 +1,30 @@ +import { validate } from 'class-validator'; +import { CreateMilestoneDto } from './create-milestone.dto'; +import { AssetType } from '../../common/enums'; + +describe('CreateMilestoneDto', () => { + it('accepts a valid milestone DTO with future deadline', async () => { + const dto = new CreateMilestoneDto(); + dto.repositoryId = '123e4567-e89b-12d3-a456-426614174000'; + dto.title = 'Milestone 1'; + dto.budget = '1000'; + dto.asset = AssetType.USDC; + dto.deadline = new Date(Date.now() + 86400000).toISOString(); + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects a past deadline', async () => { + const dto = new CreateMilestoneDto(); + dto.repositoryId = '123e4567-e89b-12d3-a456-426614174000'; + dto.title = 'Milestone 1'; + dto.budget = '1000'; + dto.asset = AssetType.USDC; + dto.deadline = new Date(Date.now() - 86400000).toISOString(); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'deadline')).toBe(true); + }); +}); diff --git a/src/milestones/dto/create-milestone.dto.ts b/src/milestones/dto/create-milestone.dto.ts index d98ef88..1e03fd3 100644 --- a/src/milestones/dto/create-milestone.dto.ts +++ b/src/milestones/dto/create-milestone.dto.ts @@ -5,6 +5,7 @@ import { IsMoneyAmount, IsSupportedEscrowAsset, } from '../../common/validators/money.validator'; +import { IsFutureDate } from '../../common/validators/future-date.validator'; export class CreateMilestoneDto { @ApiProperty() @@ -36,5 +37,6 @@ export class CreateMilestoneDto { @ApiProperty({ required: false }) @IsOptional() @IsISO8601() + @IsFutureDate() deadline?: string; } diff --git a/src/teams/team-split.util.ts b/src/teams/team-split.util.ts index 88196d9..b7f4cef 100644 --- a/src/teams/team-split.util.ts +++ b/src/teams/team-split.util.ts @@ -4,10 +4,15 @@ export interface SplitLike { percentage: number; } -/** Validates that a set of team member split percentages sums to exactly 100 (within tolerance). */ -export function validateSplitPercentages(splits: SplitLike[]): void { - if (splits.length === 0) { - throw new BadRequestException('A team must have at least one member split'); +/** Validates that a set of split percentages sums to exactly 100 (within tolerance) and each is positive and <= 100. */ +export function validateSplitPercentages( + splits: SplitLike[], + errorMessagePrefix = 'Team', +): void { + if (!splits || splits.length === 0) { + throw new BadRequestException( + `${errorMessagePrefix === 'Team' ? 'A team' : errorMessagePrefix} must have at least one member split`, + ); } if (splits.some((s) => s.percentage <= 0 || s.percentage > 100)) { throw new BadRequestException( @@ -17,7 +22,7 @@ export function validateSplitPercentages(splits: SplitLike[]): void { const total = splits.reduce((sum, s) => sum + s.percentage, 0); if (Math.abs(total - 100) > 0.01) { throw new BadRequestException( - `Team split percentages must sum to 100, got ${total.toFixed(2)}`, + `${errorMessagePrefix} split percentages must sum to 100, got ${total.toFixed(2)}`, ); } } diff --git a/test/users.e2e-spec.ts b/test/users.e2e-spec.ts index 4fee806..f983d0c 100644 --- a/test/users.e2e-spec.ts +++ b/test/users.e2e-spec.ts @@ -16,9 +16,7 @@ describe('UsersController (e2e)', () => { beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ controllers: [UsersController], - providers: [ - { provide: UsersService, useValue: mockUsersService }, - ], + providers: [{ provide: UsersService, useValue: mockUsersService }], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => false }) // Simulate unauthenticated @@ -34,9 +32,7 @@ describe('UsersController (e2e)', () => { describe('GET /users', () => { it('should reject unauthenticated requests with 401', () => { - return request(app.getHttpServer()) - .get('/users') - .expect(403); // Assuming the guard returns 403 when not authorized + return request(app.getHttpServer()).get('/users').expect(403); // Assuming the guard returns 403 when not authorized }); });