Skip to content
Open
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -93,6 +94,9 @@
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": [
".*\\.integration\\.spec\\.ts$"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
Expand Down
2 changes: 2 additions & 0 deletions src/bounties/dto/create-bounty.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IsMoneyAmount,
IsSupportedEscrowAsset,
} from '../../common/validators/money.validator';
import { IsFutureDate } from '../../common/validators/future-date.validator';

export class CreateBountyDto {
@ApiProperty({
Expand Down Expand Up @@ -35,5 +36,6 @@ export class CreateBountyDto {
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
@IsFutureDate()
deadline?: string;
}
50 changes: 50 additions & 0 deletions src/common/validators/future-date.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
35 changes: 35 additions & 0 deletions src/common/validators/future-date.validator.ts
Original file line number Diff line number Diff line change
@@ -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`;
},
},
});
};
}
16 changes: 2 additions & 14 deletions src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}

/**
Expand Down
30 changes: 30 additions & 0 deletions src/milestones/dto/create-milestone.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions src/milestones/dto/create-milestone.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IsMoneyAmount,
IsSupportedEscrowAsset,
} from '../../common/validators/money.validator';
import { IsFutureDate } from '../../common/validators/future-date.validator';

export class CreateMilestoneDto {
@ApiProperty()
Expand Down Expand Up @@ -36,5 +37,6 @@ export class CreateMilestoneDto {
@ApiProperty({ required: false })
@IsOptional()
@IsISO8601()
@IsFutureDate()
deadline?: string;
}
15 changes: 10 additions & 5 deletions src/teams/team-split.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)}`,
);
}
}
Expand Down
8 changes: 2 additions & 6 deletions test/users.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
});
});

Expand Down