From a57394607b990c9c9c29f80f0f106d7ead462875 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Fri, 21 Aug 2026 21:18:34 +0000 Subject: [PATCH] fix(backend): enable forbidNonWhitelisted, batch team split inserts, remove dead split math, and harden DTO/pipe/OAuth scopes (#170, #150, #127, #112, #111, #104, #98) --- src/auth/auth.module.ts | 7 ++++--- src/auth/strategies/github.strategy.ts | 2 +- src/bounties/bounties.controller.ts | 7 +++++-- src/main.ts | 2 +- src/teams/dto/create-team.dto.ts | 4 ++++ src/teams/team-split.util.spec.ts | 24 +----------------------- src/teams/team-split.util.ts | 11 ----------- src/teams/teams.service.spec.ts | 14 ++++++++------ src/teams/teams.service.ts | 19 ++++++++----------- 9 files changed, 32 insertions(+), 58 deletions(-) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9375490..8274cbe 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import type { StringValue } from 'ms'; import { UsersModule } from '../users/users.module'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; @@ -15,11 +16,11 @@ import { AppConfig } from '../config/configuration'; PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ inject: [ConfigService], - useFactory: (configService: ConfigService) => { + useFactory: (configService: ConfigService): JwtModuleOptions => { const jwt = configService.get('jwt', { infer: true }); return { secret: jwt.secret, - signOptions: { expiresIn: jwt.expiresIn as unknown as number }, + signOptions: { expiresIn: jwt.expiresIn as StringValue | number }, }; }, }), diff --git a/src/auth/strategies/github.strategy.ts b/src/auth/strategies/github.strategy.ts index 9567e36..42132a2 100644 --- a/src/auth/strategies/github.strategy.ts +++ b/src/auth/strategies/github.strategy.ts @@ -21,7 +21,7 @@ export class GithubStrategy extends PassportStrategy(GitHubStrategy, 'github') { clientID: github.clientId, clientSecret: github.clientSecret, callbackURL: github.oauthCallbackUrl, - scope: ['user:email', 'read:org'], + scope: ['user:email'], }); } diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index d2e6056..2318b83 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseEnumPipe, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { BountiesService } from './bounties.service'; import { CreateBountyDto } from './dto/create-bounty.dto'; @@ -23,7 +23,10 @@ export class BountiesController { } @Get() - list(@Query('status') status?: BountyStatus) { + list( + @Query('status', new ParseEnumPipe(BountyStatus, { optional: true })) + status?: BountyStatus, + ) { return this.bountiesService.list(status); } diff --git a/src/main.ts b/src/main.ts index bb70709..a66da04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,7 @@ async function bootstrap() { new ValidationPipe({ whitelist: true, transform: true, - forbidNonWhitelisted: false, + forbidNonWhitelisted: true, }), ); app.useGlobalFilters(new GlobalExceptionFilter()); diff --git a/src/teams/dto/create-team.dto.ts b/src/teams/dto/create-team.dto.ts index d9bb676..dbbede2 100644 --- a/src/teams/dto/create-team.dto.ts +++ b/src/teams/dto/create-team.dto.ts @@ -6,6 +6,8 @@ import { IsOptional, IsString, IsUUID, + Max, + Min, ValidateNested, } from 'class-validator'; @@ -21,6 +23,8 @@ export class TeamMemberSplitDto { @ApiProperty({ example: 40 }) @IsNumber() + @Min(0.01) + @Max(100) percentage: number; } diff --git a/src/teams/team-split.util.spec.ts b/src/teams/team-split.util.spec.ts index 70fb2d0..949efc4 100644 --- a/src/teams/team-split.util.spec.ts +++ b/src/teams/team-split.util.spec.ts @@ -1,8 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { - computeSplitShares, - validateSplitPercentages, -} from './team-split.util'; +import { validateSplitPercentages } from './team-split.util'; describe('team split percentage math', () => { it('accepts splits that sum to exactly 100', () => { @@ -46,23 +43,4 @@ describe('team split percentage math', () => { it('rejects an empty split list', () => { expect(() => validateSplitPercentages([])).toThrow(BadRequestException); }); - - it('computeSplitShares divides an amount proportionally', () => { - const shares = computeSplitShares(1000, [ - { percentage: 40 }, - { percentage: 40 }, - { percentage: 20 }, - ]); - expect(shares).toEqual([400, 400, 200]); - }); - - it('computeSplitShares handles uneven thirds without losing precision beyond 7dp', () => { - const shares = computeSplitShares(100, [ - { percentage: 33.33 }, - { percentage: 33.33 }, - { percentage: 33.34 }, - ]); - const total = shares.reduce((a, b) => a + b, 0); - expect(total).toBeCloseTo(100, 5); - }); }); diff --git a/src/teams/team-split.util.ts b/src/teams/team-split.util.ts index 88196d9..aff39e3 100644 --- a/src/teams/team-split.util.ts +++ b/src/teams/team-split.util.ts @@ -21,14 +21,3 @@ export function validateSplitPercentages(splits: SplitLike[]): void { ); } } - -/** Computes each member's absolute payout share for a given total bounty amount. */ -export function computeSplitShares( - totalAmount: number, - splits: SplitLike[], -): number[] { - validateSplitPercentages(splits); - return splits.map( - (s) => Math.round(((totalAmount * s.percentage) / 100) * 1e7) / 1e7, - ); -} diff --git a/src/teams/teams.service.spec.ts b/src/teams/teams.service.spec.ts index b2188d5..0e73499 100644 --- a/src/teams/teams.service.spec.ts +++ b/src/teams/teams.service.spec.ts @@ -54,7 +54,11 @@ describe('TeamsService', () => { expect(teamRepo.save).not.toHaveBeenCalled(); }); - it('saves the team and one split per member when percentages sum to 100', async () => { + it('saves the team and splits in batch when percentages sum to 100', async () => { + splitRepo.save.mockResolvedValue([ + { id: 'split-u1', teamId: 't1', userId: 'u1', role: 'frontend', percentage: '60.00' }, + { id: 'split-u2', teamId: 't1', userId: 'u2', role: null, percentage: '40.00' }, + ]); const team = await service.create({ name: 'Team A', createdById: 'creator-1', @@ -67,23 +71,21 @@ describe('TeamsService', () => { expect(teamRepo.save).toHaveBeenCalledWith( expect.objectContaining({ name: 'Team A', createdById: 'creator-1' }), ); - expect(splitRepo.save).toHaveBeenCalledTimes(2); - expect(splitRepo.save).toHaveBeenCalledWith( + expect(splitRepo.save).toHaveBeenCalledTimes(1); + expect(splitRepo.save).toHaveBeenCalledWith([ expect.objectContaining({ teamId: 't1', userId: 'u1', role: 'frontend', percentage: '60.00', }), - ); - expect(splitRepo.save).toHaveBeenCalledWith( expect.objectContaining({ teamId: 't1', userId: 'u2', role: null, percentage: '40.00', }), - ); + ]); expect(team.splits).toHaveLength(2); }); diff --git a/src/teams/teams.service.ts b/src/teams/teams.service.ts index 28f40b8..2fd03ff 100644 --- a/src/teams/teams.service.ts +++ b/src/teams/teams.service.ts @@ -24,18 +24,15 @@ export class TeamsService { }), ); - team.splits = await Promise.all( - dto.members.map((m) => - this.splitRepo.save( - this.splitRepo.create({ - teamId: team.id, - userId: m.userId, - role: m.role ?? null, - percentage: m.percentage.toFixed(2), - }), - ), - ), + const splitEntities = dto.members.map((m) => + this.splitRepo.create({ + teamId: team.id, + userId: m.userId, + role: m.role ?? null, + percentage: m.percentage.toFixed(2), + }), ); + team.splits = await this.splitRepo.save(splitEntities); return team; }