diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 728fce5..bf5e81d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,15 +91,3 @@ jobs: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/mergefi NODE_ENV: test run: npm run test:e2e - run: | - # The e2e specs build their TestingModule from mocked providers and - # overrideGuard(...)-stubbed auth — none drive a real passport-github2 - # handshake, so GITHUB_CLIENT_ID/SECRET were never the right gate - # (#165). What they actually need is a reachable Postgres, which the - # `postgres` service container above always provides. - if [ -z "${DATABASE_URL:-}" ]; then - echo "Skipping E2E tests: DATABASE_URL is not set." - exit 0 - fi - npm run test:e2e - shell: bash diff --git a/eslint.config.mjs b/eslint.config.mjs index 8048244..9700c7f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,4 +32,20 @@ export default tseslint.config( "prettier/prettier": ["error", { endOfLine: "auto" }], }, }, + { + // Jest mocks (mocked repositories, `jest.fn()` return values, supertest's + // `app.getHttpServer()`) are deliberately loosely typed scaffolding, not + // production logic — the no-unsafe-* / require-await rules exist to catch + // real bugs in application code and produce mostly noise against mocks. + files: ['**/*.spec.ts', 'test/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-function-type': 'off', + '@typescript-eslint/require-await': 'off', + }, + }, ); diff --git a/src/analytics/analytics.integration.spec.ts b/src/analytics/analytics.integration.spec.ts index c7845a1..77b7ec5 100644 --- a/src/analytics/analytics.integration.spec.ts +++ b/src/analytics/analytics.integration.spec.ts @@ -49,17 +49,29 @@ describe('Analytics SQL aggregation (integration)', () => { const SEED_SPONSORS = 40; beforeAll(async () => { + const url = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/mergefi'; + dataSource = new DataSource({ type: 'postgres', - url: - process.env.DATABASE_URL ?? - 'postgresql://postgres:postgres@localhost:5432/mergefi', + url, schema: 'analytics_itest', entities, synchronize: true, dropSchema: true, }); try { + // TypeORM's dropSchema/synchronize operate within an existing named + // schema — they don't create the schema itself, unlike the default + // `public` schema every Postgres database already has. Ensure + // analytics_itest exists first via a throwaway connection on the + // default schema. + const bootstrap = new DataSource({ type: 'postgres', url }); + await bootstrap.initialize(); + await bootstrap.query('CREATE SCHEMA IF NOT EXISTS analytics_itest'); + await bootstrap.destroy(); + await dataSource.initialize(); } catch (err) { console.warn( diff --git a/src/app.module.ts b/src/app.module.ts index af908d1..ed2b2cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,9 +22,6 @@ import { ReputationModule } from './reputation/reputation.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { IdempotencyModule } from './common/idempotency/idempotency.module'; -// Import the new RolesGuard we created -import { RolesGuard } from './roles.guard'; - @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [configuration] }), @@ -65,13 +62,6 @@ import { RolesGuard } from './roles.guard'; provide: APP_GUARD, useClass: ThrottlerGuard, }, - // This registers RolesGuard globally to secure all role permissions across the entire app - // This registers RolesGuard globally to secure all role permissions - - { - provide: APP_GUARD, - useClass: RolesGuard, - }, ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 3914ab6..54b515e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -64,7 +64,7 @@ export class AuthController { @Post('handoff') @HttpCode(200) @ApiExcludeEndpoint() - async exchangeHandoff(@Body('code') code: string) { + exchangeHandoff(@Body('code') code: string) { if (!code || typeof code !== 'string') { throw new UnauthorizedException('Missing handoff code'); } @@ -78,7 +78,7 @@ export class AuthController { @Post('exchange') @HttpCode(200) @ApiExcludeEndpoint() - async exchangeHandoffAlias(@Body('code') code: string) { + exchangeHandoffAlias(@Body('code') code: string) { // Alias for POST /auth/exchange — same single-use semantics as /handoff. if (!code || typeof code !== 'string') { throw new UnauthorizedException('Missing handoff code'); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 73e7538..937530e 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { StringValue } from 'ms'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; @@ -23,10 +24,17 @@ import { UsersModule } from '../users/users.module'; const jwt = configService.get('jwt', { infer: true }); return { secret: jwt.secret, - signOptions: { - expiresIn: jwt.expiresIn + signOptions: { + // jwt.expiresIn is a free-form configured string (env var, + // default '7d') — JwtModuleOptions wants jsonwebtoken's + // ms.StringValue template-literal type, which can't be derived + // from a plain `string` at the type level without a runtime + // format check. The configured value is a duration string by + // contract (see config/configuration.ts), so this is a narrow, + // deliberate assertion rather than a blanket `any`. + expiresIn: jwt.expiresIn as StringValue, }, - } as any; + }; }, }), ], diff --git a/src/auth/strategies/github.strategy.ts b/src/auth/strategies/github.strategy.ts index 68e5e93..76d362a 100644 --- a/src/auth/strategies/github.strategy.ts +++ b/src/auth/strategies/github.strategy.ts @@ -1,25 +1,34 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-github2'; +import { Strategy, Profile } from 'passport-github2'; +import { VerifyCallback } from 'passport-oauth2'; import { ConfigService } from '@nestjs/config'; +import { AppConfig } from '../../config/configuration'; @Injectable() export class GithubStrategy extends PassportStrategy(Strategy, 'github') { - constructor(configService: ConfigService) { + constructor(configService: ConfigService) { // We completely override validation layers right here. - // If the config system returns an empty string or undefined, + // If the config system returns an empty string or undefined, // it automatically uses static string fallbacks so Passport NEVER crashes. - const githubConfig = configService.get('github') || {}; - + const githubConfig = configService.get('github', { infer: true }); + super({ - clientID: githubConfig.clientId || 'mock_client_id_12345', - clientSecret: githubConfig.clientSecret || 'mock_secret_key_67890', - callbackURL: githubConfig.oauthCallbackUrl || 'http://localhost:3000/api/auth/github/callback', + clientID: githubConfig?.clientId || 'mock_client_id_12345', + clientSecret: githubConfig?.clientSecret || 'mock_secret_key_67890', + callbackURL: + githubConfig?.oauthCallbackUrl || + 'http://localhost:3000/api/auth/github/callback', scope: ['user:email', 'read:org'], }); } - async validate(accessToken: string, refreshToken: string, profile: any, done: any): Promise { + validate( + accessToken: string, + refreshToken: string, + profile: Profile, + done: VerifyCallback, + ) { const { id, username, emails, photos } = profile; const user = { githubId: id, @@ -29,6 +38,6 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') { accessToken, refreshToken, }; - return done(null, user); + done(null, user); } } diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 3b9f61c..e6c0d08 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -46,7 +46,10 @@ export class BountiesController { list( @Query('status', new ParseEnumPipe(BountyStatus, { optional: true })) status?: BountyStatus, - @Query('difficulty', new ParseEnumPipe(BountyDifficulty, { optional: true })) + @Query( + 'difficulty', + new ParseEnumPipe(BountyDifficulty, { optional: true }), + ) difficulty?: BountyDifficulty, @Query('asset', new ParseEnumPipe(AssetType, { optional: true })) asset?: AssetType, diff --git a/src/bounties/bounties.module.ts b/src/bounties/bounties.module.ts index f236f16..f7b47b9 100644 --- a/src/bounties/bounties.module.ts +++ b/src/bounties/bounties.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Bounty, Team, User } from '../common/entities'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; import { BountiesService } from './bounties.service'; import { BountiesController } from './bounties.controller'; import { BountyExpiryScheduler } from './bounty-expiry.scheduler'; @@ -9,7 +10,11 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Bounty, Team, User]), + // RolesGuard and @Idempotent()'s IdempotencyInterceptor are used via + // @UseGuards/@UseInterceptors class references on BountiesController — + // see TeamsModule for why each needs its repository resolvable here + // directly rather than only through an imported/global module. + TypeOrmModule.forFeature([Bounty, Team, User, IdempotencyKey]), EscrowModule, AuthModule, ], diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index eb933e3..518072d 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -236,7 +236,7 @@ export class BountiesService { return qb.getMany(); } - approve(id: string) { + approve(id: string) { return Promise.resolve({ id, status: 'approved' }); } diff --git a/src/common/encryption.transformer.ts b/src/common/encryption.transformer.ts index da3b04e..d02445a 100644 --- a/src/common/encryption.transformer.ts +++ b/src/common/encryption.transformer.ts @@ -5,18 +5,20 @@ export class EncryptionTransformer implements ValueTransformer { to(value: string | null): string | null { if (!value) return null; try { - const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; + const secretKeyString = + process.env.ENCRYPTION_KEY || + '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; const key = Buffer.from(secretKeyString, 'hex'); const iv = randomBytes(12); - + const cipher = createCipheriv('aes-256-gcm', key, iv); let encrypted = cipher.update(value, 'utf8', 'hex'); encrypted += cipher.final('hex'); - + const authTag = cipher.getAuthTag().toString('hex'); - + return `${iv.toString('hex')}:${authTag}:${encrypted}`; - } catch (error) { + } catch { return value; } } @@ -27,19 +29,21 @@ export class EncryptionTransformer implements ValueTransformer { const [ivHex, authTagHex, encryptedDataHex] = value.split(':'); if (!ivHex || !authTagHex || !encryptedDataHex) return value; - const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; + const secretKeyString = + process.env.ENCRYPTION_KEY || + '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; const key = Buffer.from(secretKeyString, 'hex'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); - + const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(authTag); - + let decrypted = decipher.update(encryptedDataHex, 'hex', 'utf8'); decrypted += decipher.final('utf8'); - + return decrypted; - } catch (error) { + } catch { return value; } } diff --git a/src/common/entities/github-account.entity.ts b/src/common/entities/github-account.entity.ts index 6fe488b..1ba445a 100644 --- a/src/common/entities/github-account.entity.ts +++ b/src/common/entities/github-account.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import { User } from './user.entity'; @@ -7,18 +13,20 @@ const encryptionTransformer = { to: (value: string | null) => { if (!value) return null; try { - const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; + const secretKeyString = + process.env.ENCRYPTION_KEY || + '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; const key = Buffer.from(secretKeyString, 'hex'); const iv = randomBytes(12); - + const cipher = createCipheriv('aes-256-gcm', key, iv); let encrypted = cipher.update(value, 'utf8', 'hex'); encrypted += cipher.final('hex'); - + const authTag = cipher.getAuthTag().toString('hex'); - + return `${iv.toString('hex')}:${authTag}:${encrypted}`; - } catch (error) { + } catch { return value; } }, @@ -29,22 +37,24 @@ const encryptionTransformer = { const [ivHex, authTagHex, encryptedDataHex] = value.split(':'); if (!ivHex || !authTagHex || !encryptedDataHex) return value; - const secretKeyString = process.env.ENCRYPTION_KEY || '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; + const secretKeyString = + process.env.ENCRYPTION_KEY || + '64a2f98b7e3c1d5e6f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e'; const key = Buffer.from(secretKeyString, 'hex'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); - + const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(authTag); - + let decrypted = decipher.update(encryptedDataHex, 'hex', 'utf8'); decrypted += decipher.final('utf8'); - + return decrypted; - } catch (error) { + } catch { return value; } - } + }, }; @Entity('github_accounts') @@ -72,20 +82,19 @@ export class GithubAccount { @JoinColumn({ name: 'userId' }) user: User; - @Column({ - type: 'varchar', - nullable: true, + @Column({ + type: 'varchar', + nullable: true, select: false, - transformer: encryptionTransformer + transformer: encryptionTransformer, }) accessToken: string | null; - @Column({ + @Column({ type: 'varchar', nullable: true, select: false, - transformer: encryptionTransformer - + transformer: encryptionTransformer, }) refreshToken: string | null; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 1552e3a..cb28124 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -81,7 +81,9 @@ export default (): AppConfig => ({ github: { clientId: process.env.GITHUB_CLIENT_ID || 'mock_client_id_12345', clientSecret: process.env.GITHUB_CLIENT_SECRET || 'mock_secret_key_67890', - oauthCallbackUrl: process.env.GITHUB_OAUTH_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback', + oauthCallbackUrl: + process.env.GITHUB_OAUTH_CALLBACK_URL || + 'http://localhost:3000/api/auth/github/callback', apiToken: process.env.GITHUB_API_TOKEN ?? '', webhookSecret: process.env.GITHUB_WEBHOOK_SECRET ?? '', }, diff --git a/src/config/validate-required-config.ts b/src/config/validate-required-config.ts index dbe3596..8008c69 100644 --- a/src/config/validate-required-config.ts +++ b/src/config/validate-required-config.ts @@ -65,7 +65,11 @@ export function collectConfigIssues(config: RequiredConfig): ConfigIssue[] { 'JWT_SECRET is still the insecure dev default — set a long random value', }); } else { - requireNonEmpty('JWT_SECRET', config.jwt.secret, 'session tokens cannot be signed'); + requireNonEmpty( + 'JWT_SECRET', + config.jwt.secret, + 'session tokens cannot be signed', + ); } if ( diff --git a/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts b/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts index ed6d25c..411bae6 100644 --- a/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts +++ b/src/database/migrations/1784600000000-AddEscrowSponsorIdStatusIndex.ts @@ -17,8 +17,6 @@ export class AddEscrowSponsorIdStatusIndex1784600000000 implements MigrationInte } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DROP INDEX IF EXISTS "IDX_escrow_sponsor_status"`, - ); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_escrow_sponsor_status"`); } } diff --git a/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts b/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts index 6c14bf7..89a6ba2 100644 --- a/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts +++ b/src/database/migrations/1784800000000-AddEscrowOnChainIdAndDeadline.ts @@ -17,9 +17,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * and `EscrowService` falls back to the parent id / configured default * when either is absent. */ -export class AddEscrowOnChainIdAndDeadline1784800000000 - implements MigrationInterface -{ +export class AddEscrowOnChainIdAndDeadline1784800000000 implements MigrationInterface { name = 'AddEscrowOnChainIdAndDeadline1784800000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts b/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts index 370f657..01d0e32 100644 --- a/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts +++ b/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts @@ -6,9 +6,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * `title`, or a maintenance pool `name` (#151). Matches the `@MaxLength(...)` * constraints added to the corresponding DTOs. */ -export class BoundFreeTextColumnLengths1784800000000 - implements MigrationInterface -{ +export class BoundFreeTextColumnLengths1784800000000 implements MigrationInterface { name = 'BoundFreeTextColumnLengths1784800000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/src/escrow/escrow.controller.spec.ts b/src/escrow/escrow.controller.spec.ts index e6cf5ff..87eb24e 100644 --- a/src/escrow/escrow.controller.spec.ts +++ b/src/escrow/escrow.controller.spec.ts @@ -1,6 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { EscrowController } from './escrow.controller'; import { EscrowService } from './escrow.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyInterceptor } from '../common/idempotency/idempotency.interceptor'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; describe('EscrowController', () => { let controller: EscrowController; @@ -21,12 +27,28 @@ describe('EscrowController', () => { provide: EscrowService, useValue: mockEscrowService, }, + IdempotencyInterceptor, + Reflector, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { + findOneBy: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, ], - }).compile(); + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); // Bypass strict type checking for the controller mock initialization controller = module.get(EscrowController); - + // Dynamically inject properties to satisfy outdated test suites const fallbackController = controller as any; fallbackController.fund = mockEscrowService.fund; diff --git a/src/escrow/escrow.controller.ts b/src/escrow/escrow.controller.ts index 517974d..afbbe97 100644 --- a/src/escrow/escrow.controller.ts +++ b/src/escrow/escrow.controller.ts @@ -1,32 +1,76 @@ -import { Controller, Post, Param, ParseUUIDPipe, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; // Added Throttle decorator import +import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums'; import { EscrowService } from './escrow.service'; +import { FundEscrowDto } from './dto/fund-escrow.dto'; +import { ReleaseEscrowDto } from './dto/release-escrow.dto'; +import { SplitReleaseDto } from './dto/split-release.dto'; +import { toPublicEscrow } from './escrow-response.mapper'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('escrow') @Controller('escrow') export class EscrowController { constructor(private readonly escrowService: EscrowService) {} + @Idempotent('escrow.fund') + @Post('fund') + async fund(@Body() dto: FundEscrowDto) { + return toPublicEscrow(await this.escrowService.fund(dto)); + } + + @Get(':id') + async findOne(@Param('id', new ParseUUIDPipe()) id: string) { + return toPublicEscrow(await this.escrowService.findOne(id)); + } + // High-value mutation protection (Requirement: max 1 req/sec against replay/DoS) @Throttle({ short: { limit: 1, ttl: 1000 } }) + @Idempotent('escrow.release') @Post(':id/release') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.MAINTAINER) - async releaseEscrow(@Param('id', new ParseUUIDPipe()) id: string) { - return this.escrowService.release(id, '', ''); // Maps to your underlying service arguments + async release( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: ReleaseEscrowDto, + ) { + return toPublicEscrow( + await this.escrowService.release( + id, + dto.recipientAddress, + dto.recipientId, + ), + ); + } + + @Idempotent('escrow.splitRelease') + @Post(':id/split-release') + splitRelease( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: SplitReleaseDto, + ) { + return this.escrowService.splitRelease(id, dto.recipients); } // High-value mutation protection (Requirement: max 1 req/sec against replay/DoS) @Throttle({ short: { limit: 1, ttl: 1000 } }) + @Idempotent('escrow.refund') @Post(':id/refund') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.MAINTAINER, UserRole.SPONSOR) - async refundEscrow(@Param('id', new ParseUUIDPipe()) id: string) { - return this.escrowService.refund(id); + async refund(@Param('id', new ParseUUIDPipe()) id: string) { + return toPublicEscrow(await this.escrowService.refund(id)); } } diff --git a/src/escrow/escrow.module.ts b/src/escrow/escrow.module.ts index 735a073..ee97abe 100644 --- a/src/escrow/escrow.module.ts +++ b/src/escrow/escrow.module.ts @@ -1,12 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Escrow, Payment, User } from '../common/entities'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; import { EscrowService } from './escrow.service'; import { EscrowController } from './escrow.controller'; import { SorobanClientService } from './soroban-client.service'; @Module({ - imports: [TypeOrmModule.forFeature([Escrow, Payment, User])], + imports: [ + // See TeamsModule/BountiesModule for why RolesGuard and @Idempotent() + // need User/IdempotencyKey resolvable here directly. + TypeOrmModule.forFeature([Escrow, Payment, User, IdempotencyKey]), + ], controllers: [EscrowController], providers: [EscrowService, SorobanClientService], exports: [EscrowService, SorobanClientService], diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index 83d59ef..6a8a736 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -6,6 +6,7 @@ import { EscrowService } from './escrow.service'; import { SorobanClientService } from './soroban-client.service'; import { Escrow, Payment, User } from '../common/entities'; import { AssetType, EscrowStatus, PaymentStatus } from '../common/enums'; +import { TOTAL_BASIS_POINTS } from './split-math.util'; describe('EscrowService', () => { let service: EscrowService; @@ -23,7 +24,6 @@ describe('EscrowService', () => { tokenContractId: jest.Mock; escrowDeadlineSeconds: number; }; - let soroban: { invoke: jest.Mock }; let dataSource: { transaction: jest.Mock }; beforeEach(async () => { @@ -288,16 +288,20 @@ describe('EscrowService', () => { amount: '50', asset: AssetType.USDC, bountyId: 'bounty-3', + onChainId: '9100', }); const escrow = await service.release('escrow-3', 'GRECIPIENT', 'user-1'); expect(escrow.status).toBe(EscrowStatus.RELEASED); // Distinct from releasePartial's 'release_partial' method name (#159). - expect(soroban.invoke).toHaveBeenCalledWith('release', [ - 'bounty-3', - 'GRECIPIENT', - ]); + // A single recipient is the degenerate [(addr, 10_000)] case of the + // release() recipients vector (#161). + expect(soroban.invoke).toHaveBeenCalledWith( + 'release', + [9100n, [['GRECIPIENT', TOTAL_BASIS_POINTS]]], + {}, + ); expect(paymentRepo.save).toHaveBeenCalledWith( expect.objectContaining({ recipientAddress: 'GRECIPIENT', @@ -376,15 +380,10 @@ describe('EscrowService', () => { ); expect(soroban.invoke).toHaveBeenCalledWith( - 'release', - [9100n, 'GRECIPIENT', 300_000_000n], + 'release_partial', + ['milestone-1', 'GRECIPIENT', 300_000_000n], {}, ); - expect(soroban.invoke).toHaveBeenCalledWith('release_partial', [ - 'milestone-1', - 'GRECIPIENT', - 300_000_000n, - ]); expect(paymentRepo.save).toHaveBeenCalledWith( expect.objectContaining({ escrowId: 'escrow-partial', @@ -432,15 +431,10 @@ describe('EscrowService', () => { expect(soroban.invoke).toHaveBeenNthCalledWith( 2, - 'release', - [9100n, 'GB', 600_000_000n], + 'release_partial', + ['milestone-1', 'GB', 600_000_000n], {}, ); - expect(soroban.invoke).toHaveBeenNthCalledWith(2, 'release_partial', [ - 'milestone-1', - 'GB', - 600_000_000n, - ]); expect(escrowRepo.save).toHaveBeenCalledWith( expect.objectContaining({ status: EscrowStatus.RELEASED, diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 28e6141..da43a20 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -14,14 +14,14 @@ import { isValidMoneyAmount, stroopsToAmount, } from '../common/validators/money.validator'; -import { - ContractInvocationResult, - SorobanClientService +import { + ContractInvocationResult, + SorobanClientService, } from './soroban-client.service'; -import { +import { apportionBasisPoints, splitStroops, - TOTAL_BASIS_POINTS + TOTAL_BASIS_POINTS, } from './split-math.util'; import { validatePercentageSplits } from '../common/validators/split-percentage.validator'; @@ -193,7 +193,9 @@ export class EscrowService { const result = await this.invokeRelease( escrow, 'splitRelease', - recipients.map((r, i) => [r.recipientAddress, bps[i]] as [string, number]), + recipients.map( + (r, i) => [r.recipientAddress, bps[i]] as [string, number], + ), ); const shares = splitStroops(totalStroops, bps); @@ -268,18 +270,21 @@ export class EscrowService { await this.assertRecipientsMatchUsers([{ recipientAddress, recipientId }]); - const result = await this.invokeOnLockedEscrow( + const result = await this.invokeOnLockedEscrow( escrow, 'releasePartial', () => - this.soroban.invoke('release_partial', [ - escrow.milestoneId ?? escrow.bountyId ?? escrow.id, - recipientAddress, - this.toStroops(amount), - ], this.contractOpts(escrow)), + this.soroban.invoke( + 'release_partial', + [ + escrow.milestoneId ?? escrow.bountyId ?? escrow.id, + recipientAddress, + this.toStroops(amount), + ], + this.contractOpts(escrow), + ), ); - // The Payment insert and the (conditional) escrow-status flip share one // transaction so the two can't diverge — same guarantee as release() // and splitRelease() (#154). @@ -442,10 +447,10 @@ export class EscrowService { * rather than only a server log line (#89). The status deliberately stays * LOCKED — the funds are still held and the operation can be retried. */ - private async invokeOnLockedEscrow( - escrow: any, + private async invokeOnLockedEscrow( + escrow: Escrow, operation: string, - call: () => Promise + call: () => Promise, ): Promise { try { return await call(); @@ -463,7 +468,6 @@ export class EscrowService { } } - /** * The escrow contract's single payout entrypoint (#161): * `release(issue_id: u64, recipients: Vec<(Address, u32)>)`. A single diff --git a/src/escrow/soroban-client.service.spec.ts b/src/escrow/soroban-client.service.spec.ts index 68d86b3..d91a5ec 100644 --- a/src/escrow/soroban-client.service.spec.ts +++ b/src/escrow/soroban-client.service.spec.ts @@ -77,9 +77,9 @@ describe('SorobanClientService', () => { describe('contract resolvers (#157)', () => { it('exposes the configured escrow contract id', () => { - expect(makeService({ escrowContractId: 'CESCROW' }).escrowContractId).toBe( - 'CESCROW', - ); + expect( + makeService({ escrowContractId: 'CESCROW' }).escrowContractId, + ).toBe('CESCROW'); }); it('returns the dedicated maintenance-pool contract id when set', () => { @@ -139,12 +139,10 @@ describe('SorobanClientService', () => { jest .spyOn(rpc.Server.prototype, 'simulateTransaction') .mockResolvedValue({} as never); - jest - .spyOn(rpc.Server.prototype, 'sendTransaction') - .mockResolvedValue({ - status: 'PENDING', - hash: 'mock-hash', - } as never); + jest.spyOn(rpc.Server.prototype, 'sendTransaction').mockResolvedValue({ + status: 'PENDING', + hash: 'mock-hash', + } as never); jest .spyOn(rpc.Server.prototype, 'getTransaction') .mockResolvedValue( @@ -192,12 +190,10 @@ describe('SorobanClientService', () => { }); it('throws when transaction submission errors', async () => { - jest - .spyOn(rpc.Server.prototype, 'sendTransaction') - .mockResolvedValue({ - status: 'ERROR', - errorResult: { code: -1 }, - } as never); + jest.spyOn(rpc.Server.prototype, 'sendTransaction').mockResolvedValue({ + status: 'ERROR', + errorResult: { code: -1 }, + } as never); await expect(service.invoke('refund', ['ref-1'])).rejects.toThrow( 'Soroban transaction submission failed', @@ -219,12 +215,10 @@ describe('SorobanClientService', () => { jest .spyOn(rpc.Server.prototype, 'simulateTransaction') .mockResolvedValue({} as never); - jest - .spyOn(rpc.Server.prototype, 'sendTransaction') - .mockResolvedValue({ - status: 'PENDING', - hash: 'mock-hash', - } as never); + jest.spyOn(rpc.Server.prototype, 'sendTransaction').mockResolvedValue({ + status: 'PENDING', + hash: 'mock-hash', + } as never); jest.useFakeTimers(); }); @@ -351,10 +345,8 @@ describe('SorobanClientService', () => { }); it('encodes a Vec<(Address, u32)> recipients list element-by-element (#161)', () => { - const a = - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAWHV'; - const b = - 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAA'; + const a = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAWHV'; + const b = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAA'; const encoded = ( service as unknown as { toScVal(v: unknown): unknown } diff --git a/src/escrow/soroban-client.service.ts b/src/escrow/soroban-client.service.ts index fa6a02b..f610833 100644 --- a/src/escrow/soroban-client.service.ts +++ b/src/escrow/soroban-client.service.ts @@ -10,6 +10,7 @@ import { nativeToScVal, rpc, scValToNative, + xdr, } from '@stellar/stellar-sdk'; import { AssetType } from '../common/enums'; import { AppConfig } from '../config/configuration'; @@ -158,7 +159,7 @@ export class SorobanClientService { const contract = this.getContract(opts.contractId); const account = await this.server.getAccount(keypair.publicKey()); -const scArgs = args.map((arg) => this.toScVal(arg)) as any[]; + const scArgs = args.map((arg) => this.toScVal(arg)); const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -218,7 +219,7 @@ const scArgs = args.map((arg) => this.toScVal(arg)) as any[]; ); } - private toScVal(value: unknown): unknown { + private toScVal(value: unknown): xdr.ScVal { if (Buffer.isBuffer(value) || value instanceof Uint8Array) { // BytesN<32> arguments (metadata hashes, description hashes, etc.) // arrive as raw bytes, not strings — without this branch they fell diff --git a/src/github/github-sync.service.spec.ts b/src/github/github-sync.service.spec.ts index eb06ab2..a32b7d4 100644 --- a/src/github/github-sync.service.spec.ts +++ b/src/github/github-sync.service.spec.ts @@ -31,21 +31,6 @@ function issuePage(items: Array>) { }; } -/** - * Mimics `octokit.paginate.iterator`'s async-generator contract. A plain - * (non-async) generator satisfies `for await...of` just as well when there's - * nothing to actually await between yields. - */ -function* pagesThenThrow( - pages: Array>, - error?: Error, -) { - for (const page of pages) { - yield page; - } - if (error) throw error; -} - describe('GithubSyncService', () => { let service: GithubSyncService; let octokit: { @@ -126,45 +111,33 @@ describe('GithubSyncService', () => { }); describe('syncIssues', () => { - it('persists issues page by page and skips pull requests', async () => { - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow([ - issuePage([ - { - id: 1, - number: 1, - title: 'Issue one', - updated_at: '2026-01-01T00:00:00Z', - }, - { id: 2, number: 2, title: 'A PR', pull_request: {} }, - ]), - issuePage([ - { - id: 3, - number: 3, - title: 'Issue three', - updated_at: '2026-01-02T00:00:00Z', - }, - ]), + it('persists one page and skips pull requests', async () => { + octokit.issues.listForRepo.mockResolvedValue( + issuePage([ + { + id: 1, + number: 1, + title: 'Issue one', + updated_at: '2026-01-01T00:00:00Z', + }, + { id: 2, number: 2, title: 'A PR', pull_request: {} }, ]), ); const repository = { id: 'repo-1' } as Repository; - const saved = await service.syncIssues(repository, 'acme', 'widgets'); + const { saved } = await service.syncIssues(repository, 'acme', 'widgets'); - expect(saved).toHaveLength(2); - expect(saved.map((i) => i.title)).toEqual(['Issue one', 'Issue three']); - expect(issueRepo.save).toHaveBeenCalledTimes(2); + expect(saved).toHaveLength(1); + expect(saved.map((i) => i.title)).toEqual(['Issue one']); + expect(issueRepo.save).toHaveBeenCalledTimes(1); }); it('never persists issues that carry pull_request', async () => { - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow([ - issuePage([{ id: 9, number: 9, pull_request: { url: 'x' } }]), - ]), + octokit.issues.listForRepo.mockResolvedValue( + issuePage([{ id: 9, number: 9, pull_request: { url: 'x' } }]), ); - const saved = await service.syncIssues( + const { saved } = await service.syncIssues( { id: 'repo-1' } as Repository, 'acme', 'widgets', @@ -173,32 +146,49 @@ describe('GithubSyncService', () => { expect(issueRepo.save).not.toHaveBeenCalled(); }); - it('keeps issues already persisted before a mid-pagination failure, and reports a resumable error', async () => { + it('reports a nextPage when the response Link header says there is more', async () => { + octokit.issues.listForRepo.mockResolvedValue({ + data: issuePage([{ id: 1, number: 1 }]).data, + headers: { + ...RATE_LIMIT_HEADERS, + link: '; rel="next"', + }, + }); + + const { nextPage } = await service.syncIssues( + { id: 'repo-1' } as Repository, + 'acme', + 'widgets', + ); + expect(nextPage).toBe(2); + }); + + it('keeps issues already persisted before a mid-page failure, and reports a resumable error', async () => { const rateLimitError = Object.assign( new Error('API rate limit exceeded'), - { - status: 429, - }, + { status: 429 }, ); - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow( - [ - issuePage([ - { id: 1, number: 1, title: 'Persisted before failure' }, - ]), - ], - rateLimitError, - ), + octokit.issues.listForRepo.mockResolvedValue( + issuePage([ + { id: 1, number: 1, title: 'Persisted before failure' }, + { id: 2, number: 2, title: 'Never reached' }, + ]), ); + // The first upsert succeeds and is durably saved; the second fails + // partway through the same page, so the whole call is interrupted. + issueRepo.save + .mockImplementationOnce((issue: object) => Promise.resolve(issue)) + .mockImplementationOnce(() => Promise.reject(rateLimitError)); await expect( service.syncIssues({ id: 'repo-1' } as Repository, 'acme', 'widgets'), ).rejects.toThrow(GithubSyncInterruptedError); - // The page fetched before the 429 is durably saved, not discarded. - expect(issueRepo.save).toHaveBeenCalledTimes(1); - expect(issueRepo.save).toHaveBeenCalledWith( + // The issue upserted before the failure is durably saved, not discarded. + expect(issueRepo.save).toHaveBeenCalledTimes(2); + expect(issueRepo.save).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ title: 'Persisted before failure' }), ); expect(errorSpy).toHaveBeenCalledWith( @@ -208,15 +198,17 @@ describe('GithubSyncService', () => { it('the thrown error clearly reports how many issues survived and that re-running is safe', async () => { const networkError = new Error('ECONNRESET'); - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow( - [ - issuePage([{ id: 1, number: 1 }]), - issuePage([{ id: 2, number: 2 }]), - ], - networkError, - ), + octokit.issues.listForRepo.mockResolvedValue( + issuePage([ + { id: 1, number: 1 }, + { id: 2, number: 2 }, + { id: 3, number: 3 }, + ]), ); + issueRepo.save + .mockImplementationOnce((issue: object) => Promise.resolve(issue)) + .mockImplementationOnce((issue: object) => Promise.resolve(issue)) + .mockImplementationOnce(() => Promise.reject(networkError)); let caught: GithubSyncInterruptedError | undefined; try { @@ -333,9 +325,7 @@ describe('GithubSyncService', () => { }, headers: RATE_LIMIT_HEADERS, }); - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow([issuePage([])]), - ); + octokit.issues.listForRepo.mockResolvedValue(issuePage([])); await service.syncRepository('acme', 'widgets'); @@ -358,9 +348,7 @@ describe('GithubSyncService', () => { }, headers: {}, }); - octokit.paginate.iterator.mockReturnValue( - pagesThenThrow([issuePage([])]), - ); + octokit.issues.listForRepo.mockResolvedValue(issuePage([])); await expect( service.syncRepository('acme', 'widgets'), diff --git a/src/github/github-webhook-payload.util.ts b/src/github/github-webhook-payload.util.ts index 2987a4c..2f03381 100644 --- a/src/github/github-webhook-payload.util.ts +++ b/src/github/github-webhook-payload.util.ts @@ -1,4 +1,7 @@ -import type { GithubIssuesEventPayload, GithubPullRequestPayload } from './github-webhooks.service'; +import type { + GithubIssuesEventPayload, + GithubPullRequestPayload, +} from './github-webhooks.service'; /** * Thrown when an inbound webhook payload doesn't have the shape a handler @@ -24,10 +27,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function requireRecord( - value: unknown, - path: string, -): Record { +function requireRecord(value: unknown, path: string): Record { if (!isRecord(value)) { throw new WebhookPayloadValidationError( `"${path}" must be an object, got ${describe(value)}`, @@ -72,9 +72,10 @@ function optionalString( } /** Shared by every event-type validator: every webhook payload identifies a repository. */ -function requireRepository( - payload: Record, -): { id: number; full_name: string } { +function requireRepository(payload: Record): { + id: number; + full_name: string; +} { const repository = requireRecord(payload.repository, 'repository'); return { id: requireNumber(repository.id, 'repository.id'), @@ -106,7 +107,8 @@ export function validatePullRequestPayload( number: requireNumber(pullRequest.number, 'pull_request.number'), merged: requireBoolean(pullRequest.merged, 'pull_request.merged'), body: optionalString(pullRequest.body, 'pull_request.body'), - title: optionalString(pullRequest.title, 'pull_request.title') ?? undefined, + title: + optionalString(pullRequest.title, 'pull_request.title') ?? undefined, }, repository, }; diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index f37b1e5..e558afa 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -127,7 +127,10 @@ describe('GithubWebhooksService', () => { id: 'issue-1', bounty: { id: 'bounty-1' }, }); - bountyRepo.findOne.mockResolvedValue({ id: 'bounty-1', status: 'in_review' }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: 'in_review', + }); const payload = { action: 'closed', @@ -140,8 +143,15 @@ describe('GithubWebhooksService', () => { }, repository: { id: 1, full_name: 'a/b' }, }; - const event = await service.handleEvent('pull_request', 'delivery-3', payload, true); - expect(bountiesService.markPrClosedWithoutMerge).toHaveBeenCalledWith('bounty-1'); + const event = await service.handleEvent( + 'pull_request', + 'delivery-3', + payload, + true, + ); + expect(bountiesService.markPrClosedWithoutMerge).toHaveBeenCalledWith( + 'bounty-1', + ); expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled(); expect(event.status).toBe(WebhookEventStatus.PROCESSED); }); @@ -229,7 +239,12 @@ describe('GithubWebhooksService', () => { repository: { id: 1, full_name: 'a/b' }, }; - await service.handleEvent('pull_request', 'delivery-reopen', payload, true); + await service.handleEvent( + 'pull_request', + 'delivery-reopen', + payload, + true, + ); expect(bountiesService.markInReview).not.toHaveBeenCalled(); }); @@ -345,12 +360,15 @@ describe('GithubWebhooksService', () => { }); describe('owner/repo-qualified closing keywords', () => { - it('resolves a closing keyword qualified with the webhook\'s own owner/repo', async () => { + it("resolves a closing keyword qualified with the webhook's own owner/repo", async () => { issueRepo.findOne.mockResolvedValue({ id: 'issue-1', bounty: { id: 'bounty-1' }, }); - bountyRepo.findOne.mockResolvedValue({ id: 'bounty-1', status: 'claimed' }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: 'claimed', + }); const payload = { action: 'closed', @@ -476,7 +494,11 @@ describe('GithubWebhooksService', () => { const event = await service.handleEvent( 'pull_request', 'delivery-malformed-1', - { action: 'closed', number: 1, repository: { id: 1, full_name: 'a/b' } }, + { + action: 'closed', + number: 1, + repository: { id: 1, full_name: 'a/b' }, + }, true, ); @@ -571,7 +593,10 @@ describe('GithubWebhooksService', () => { id: 'issue-1', bounty: { id: 'bounty-1' }, }); - bountyRepo.findOne.mockResolvedValue({ id: 'bounty-1', status: 'claimed' }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: 'claimed', + }); bountiesService.markMergedAndRelease.mockRejectedValue( new Error('escrow release failed'), ); diff --git a/src/github/github.module.ts b/src/github/github.module.ts index 1014cd1..5ae9886 100644 --- a/src/github/github.module.ts +++ b/src/github/github.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Bounty, Issue, Repository, WebhookEvent } from '../common/entities'; +import { + Bounty, + Issue, + Repository, + User, + WebhookEvent, +} from '../common/entities'; import { GithubSyncService } from './github-sync.service'; import { GithubController } from './github.controller'; import { GithubWebhooksService } from './github-webhooks.service'; @@ -10,7 +16,9 @@ import { githubOctokitProvider } from './octokit.provider'; @Module({ imports: [ - TypeOrmModule.forFeature([Repository, Issue, Bounty, WebhookEvent]), + // See TeamsModule for why RolesGuard (used on GithubController) needs + // User here too. + TypeOrmModule.forFeature([Repository, Issue, Bounty, WebhookEvent, User]), BountiesModule, ], controllers: [GithubController, GithubWebhooksController], diff --git a/src/main.ts b/src/main.ts index ac0dc4d..691ff93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; -import { ValidationPipe, LoggerService, Logger } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; @@ -22,7 +22,6 @@ function resolveLogLevels(level: string): LogLevel[] { return LOG_LEVEL_MAP[level.toLowerCase()] ?? LOG_LEVEL_MAP.log; } - async function bootstrap() { // rawBody: true preserves the raw request buffer on req.rawBody, which the // GitHub webhooks controller needs to verify the HMAC-SHA256 signature. @@ -32,7 +31,7 @@ async function bootstrap() { const env = configService.get('env', { infer: true }); const logLevel = configService.get('logLevel', { infer: true }); -app.useLogger(resolveLogLevels(logLevel || 'log')); + app.useLogger(resolveLogLevels(logLevel || 'log')); // Fail fast and loudly if *any* required-in-production secret is missing — // not just JWT_SECRET. An empty GITHUB_WEBHOOK_SECRET, TREASURY_SECRET, diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index c1dd1ff..ef65b89 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -1,24 +1,97 @@ -import { Controller, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; // Added Throttle decorator import +import { Throttle } from '@nestjs/throttler'; +import { IsOptional, IsUUID } from 'class-validator'; +import { MaintenancePoolService } from './maintenance-pool.service'; +import { CreatePoolDto } from './dto/create-pool.dto'; +import { IsMoneyAmount } from '../common/validators/money.validator'; +import { IsStellarAddress } from '../common/validators/stellar-address.validator'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums'; -import { MaintenancePoolService } from './maintenance-pool.service'; + +class DepositDto { + @IsMoneyAmount() + amount: string; + + @IsStellarAddress() + funderAddress: string; +} + +class AssignRewardDto { + @IsUUID() + issueId: string; + + @IsMoneyAmount() + amount: string; + + @IsStellarAddress() + recipientAddress: string; + + @IsOptional() + @IsUUID() + recipientId?: string; +} @ApiTags('maintenance-pool') -@Controller('maintenance-pool') +@Controller('maintenance-pools') export class MaintenancePoolController { - constructor(private readonly maintenancePoolService: MaintenancePoolService) {} + constructor(private readonly poolService: MaintenancePoolService) {} + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SPONSOR, UserRole.MAINTAINER) + create(@Body() dto: CreatePoolDto) { + return this.poolService.create(dto); + } + + @Get() + list() { + return this.poolService.list(); + } + + @Get(':id') + findOne(@Param('id', new ParseUUIDPipe()) id: string) { + return this.poolService.findOne(id); + } + + @Idempotent('pool.deposit') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SPONSOR, UserRole.MAINTAINER) + @Post(':id/deposit') + deposit( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: DepositDto, + ) { + return this.poolService.deposit(id, dto.amount, dto.funderAddress); + } // High-value mutation protection (Requirement: max 1 req/sec against DoS/flooding) @Throttle({ short: { limit: 1, ttl: 1000 } }) - @Post('assign-funds') + @Idempotent('pool.assignReward') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.MAINTAINER) - async assignMaintenanceFunds() { - // Falls back safely to your underlying module service method signature - return { status: 'funds_assigned_successfully' }; + @Post(':id/assign-reward') + assignReward( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: AssignRewardDto, + ) { + return this.poolService.assignReward( + id, + dto.issueId, + dto.amount, + dto.recipientAddress, + dto.recipientId, + ); } } diff --git a/src/maintenance-pool/maintenance-pool.module.ts b/src/maintenance-pool/maintenance-pool.module.ts index bfa8811..b4d98c6 100644 --- a/src/maintenance-pool/maintenance-pool.module.ts +++ b/src/maintenance-pool/maintenance-pool.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Issue, MaintenancePool } from '../common/entities'; +import { Issue, MaintenancePool, User } from '../common/entities'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; import { MaintenancePoolService } from './maintenance-pool.service'; import { MaintenancePoolController } from './maintenance-pool.controller'; import { EscrowModule } from '../escrow/escrow.module'; @@ -8,7 +9,9 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([MaintenancePool, Issue]), + // See TeamsModule/BountiesModule for why RolesGuard and @Idempotent() + // need User/IdempotencyKey resolvable here too. + TypeOrmModule.forFeature([MaintenancePool, Issue, User, IdempotencyKey]), EscrowModule, AuthModule, ], diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index e3072f4..7dc9a4e 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -15,7 +15,7 @@ import { CreateMilestoneDto } from './dto/create-milestone.dto'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; import { IsStellarAddress } from '../common/validators/stellar-address.validator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../roles.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums'; @@ -91,6 +91,7 @@ export class MilestonesController { id, issueId, dto.recipientAddress, + dto.recipientId, ); } @@ -99,9 +100,6 @@ export class MilestonesController { @Roles(UserRole.MAINTAINER) @Post(':id/allocate') allocateBudget(@Param('id', new ParseUUIDPipe()) id: string) { - // Using a type assertion to allow dynamic route checking without altering the service file - return (this.milestonesService as any).allocateBudget - ? (this.milestonesService as any).allocateBudget(id) - : Promise.resolve({ id, status: 'budget_allocated' }); + return this.milestonesService.allocateBudget(id); } } diff --git a/src/milestones/milestones.module.ts b/src/milestones/milestones.module.ts index b582d22..ea505e2 100644 --- a/src/milestones/milestones.module.ts +++ b/src/milestones/milestones.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Issue, Milestone } from '../common/entities'; +import { Issue, Milestone, User } from '../common/entities'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; import { MilestonesService } from './milestones.service'; import { MilestonesController } from './milestones.controller'; import { EscrowModule } from '../escrow/escrow.module'; @@ -8,7 +9,9 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Milestone, Issue]), + // See TeamsModule for why RolesGuard needs User here too. Same story for + // @Idempotent()'s IdempotencyInterceptor and IdempotencyKey. + TypeOrmModule.forFeature([Milestone, Issue, User, IdempotencyKey]), EscrowModule, AuthModule, ], diff --git a/src/milestones/milestones.service.spec.ts b/src/milestones/milestones.service.spec.ts index 0390693..612bde8 100644 --- a/src/milestones/milestones.service.spec.ts +++ b/src/milestones/milestones.service.spec.ts @@ -245,7 +245,10 @@ describe('MilestonesService', () => { escrowId: 'escrow-1', budget: '500', distributed: '0', - issues: [{ id: 'i1', state: 'open' }, { id: 'i2', state: 'open' }], + issues: [ + { id: 'i1', state: 'open' }, + { id: 'i2', state: 'open' }, + ], }); const payment = await service.resolveIssue( @@ -373,7 +376,9 @@ describe('MilestonesService', () => { await expect( service.resolveIssue('m1', 'issue-1', 'RECIPIENT'), - ).rejects.toThrow('No unresolved issues left to attribute this payout to'); + ).rejects.toThrow( + 'No unresolved issues left to attribute this payout to', + ); expect(escrowService.releasePartial).not.toHaveBeenCalled(); }); diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index bb7a1b9..acd9564 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -109,10 +109,11 @@ export class MilestonesService { * are wrapped in a single DB transaction to prevent desync between the * Payment ledger and `milestone.distributed` (#117). */ - async resolveIssue( + async resolveIssue( milestoneId: string, issueId: string, - recipientAddress: string + recipientAddress: string, + recipientId?: string, ) { const milestone = await this.findOne(milestoneId); if (!milestone.escrowId) { @@ -137,7 +138,9 @@ export class MilestonesService { ); } - const openIssues = milestone.issues.filter((i) => i.state === 'open'); + const openIssues = milestone.issues.filter( + (i) => i.state === IssueState.OPEN, + ); // Reject when no issues remain open — fallback to divisor 1 would let a // single call drain the entire remaining budget (#115). @@ -147,7 +150,7 @@ export class MilestonesService { ); } - if (issue.state !== 'open') { + if (issue.state !== IssueState.OPEN) { throw new BadRequestException( `Issue ${issueId} has already been resolved for milestone ${milestoneId}`, ); @@ -159,11 +162,11 @@ export class MilestonesService { const share = Math.min(remainingBudget / unresolvedCount, remainingBudget); return this.dataSource.transaction(async (mgr) => { - // FIXED: Aligned argument signature with our 3-arg escrow service update const payment = await this.escrowService.releasePartial( milestone.escrowId!, + share.toFixed(7), recipientAddress, - share.toFixed(7) + recipientId, ); const newDistributed = (Number(milestone.distributed) + share).toFixed(7); diff --git a/src/roles.guard.ts b/src/roles.guard.ts deleted file mode 100644 index baed166..0000000 --- a/src/roles.guard.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -// Fixed Path: Explicitly looks inside the auth decorators folder -import { ROLES_KEY } from './auth/decorators/roles.decorator'; - -@Injectable() -export class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - if (!requiredRoles || requiredRoles.length === 0) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user) { - throw new ForbiddenException('Authentication session not found.'); - } - - const hasRole = Array.isArray(user.roles) - ? requiredRoles.some((role) => user.roles.includes(role)) - : requiredRoles.includes(user.role); - - if (!hasRole) { - throw new ForbiddenException('Access denied: Insufficient permissions for this role.'); - } - - return true; - } -} diff --git a/src/sponsors/sponsors.service.spec.ts b/src/sponsors/sponsors.service.spec.ts index 6999971..31958ab 100644 --- a/src/sponsors/sponsors.service.spec.ts +++ b/src/sponsors/sponsors.service.spec.ts @@ -223,7 +223,12 @@ describe('SponsorsService', () => { describe('milestoneProgress', () => { it('computes distributed / budget for each milestone', async () => { milestoneRepo.find.mockResolvedValue([ - { id: 'm1', title: 'One', budget: '100.0000000', distributed: '25.0000000' }, + { + id: 'm1', + title: 'One', + budget: '100.0000000', + distributed: '25.0000000', + }, ]); await expect(service.milestoneProgress('sponsor-1')).resolves.toEqual([ diff --git a/src/teams/teams.controller.spec.ts b/src/teams/teams.controller.spec.ts index e120c6a..2a9ba8e 100644 --- a/src/teams/teams.controller.spec.ts +++ b/src/teams/teams.controller.spec.ts @@ -1,10 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ValidationPipe } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { TeamsController } from './teams.controller'; import { TeamsService } from './teams.service'; import { CreateTeamDto } from './dto/create-team.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; describe('TeamsController', () => { let controller: TeamsController; @@ -17,18 +18,25 @@ describe('TeamsController', () => { beforeEach(async () => { teamsService = { - create: jest.fn().mockResolvedValue({ id: 't1', name: 'Team A', splits: [] }), - findOne: jest.fn().mockResolvedValue({ id: 't1', name: 'Team A', splits: [] }), + create: jest + .fn() + .mockResolvedValue({ id: 't1', name: 'Team A', splits: [] }), + findOne: jest + .fn() + .mockResolvedValue({ id: 't1', name: 'Team A', splits: [] }), updateSplits: jest.fn().mockResolvedValue([]), assignToBounty: jest.fn().mockResolvedValue({ id: 'b1', teamId: 't1' }), }; const module: TestingModule = await Test.createTestingModule({ controllers: [TeamsController], - providers: [ - { provide: TeamsService, useValue: teamsService }, - ], - }).compile(); + providers: [{ provide: TeamsService, useValue: teamsService }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(TeamsController); }); @@ -40,7 +48,7 @@ describe('TeamsController', () => { members: [{ userId: 'u1', percentage: 100 }], }; - await controller.create(dto as any); + await controller.create(dto); expect(teamsService.create).toHaveBeenCalledWith(dto); }); @@ -57,7 +65,7 @@ describe('TeamsController', () => { describe('updateSplits', () => { it('calls teamsService.updateSplits with id and members', async () => { const members = [{ userId: 'u1', percentage: 100 }]; - await controller.updateSplits('t1', members as any); + await controller.updateSplits('t1', members); expect(teamsService.updateSplits).toHaveBeenCalledWith('t1', members); }); @@ -81,7 +89,9 @@ describe('TeamsController', () => { it('rejects a body with percentage below 0.01', async () => { const dto = plainToInstance(CreateTeamDto, { name: 'Team A', - members: [{ userId: '00000000-0000-0000-0000-000000000001', percentage: 0 }], + members: [ + { userId: '00000000-0000-0000-0000-000000000001', percentage: 0 }, + ], }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); @@ -90,21 +100,29 @@ describe('TeamsController', () => { it('rejects a body with percentage above 100', async () => { const dto = plainToInstance(CreateTeamDto, { name: 'Team A', - members: [{ userId: '00000000-0000-0000-0000-000000000001', percentage: 101 }], + members: [ + { userId: '00000000-0000-0000-0000-000000000001', percentage: 101 }, + ], }); const errors = await validate(dto); expect(errors.length).toBeGreaterThan(0); }); it('accepts a valid body with percentage between 0.01 and 100', async () => { - const dto = plainToInstance(CreateTeamDto, { - name: 'Team A', - members: [{ userId: '00000000-0000-0000-0000-000000000001', percentage: 50 }], - }, { enableImplicitConversion: true }); + const dto = plainToInstance( + CreateTeamDto, + { + name: 'Team A', + members: [ + { userId: '00000000-0000-0000-0000-000000000001', percentage: 50 }, + ], + }, + { enableImplicitConversion: true }, + ); const errors = await validate(dto, { skipMissingProperties: true }); // Filter out nested validation errors since plainToInstance doesn't // fully transform nested @ValidateNested objects in test context - const topLevelErrors = errors.filter(e => e.property === 'name'); + const topLevelErrors = errors.filter((e) => e.property === 'name'); expect(topLevelErrors.length).toBe(0); }); }); diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 098e156..3fc3f97 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -1,13 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Bounty, Team, TeamMemberSplit } from '../common/entities'; +import { Bounty, Team, TeamMemberSplit, User } from '../common/entities'; import { TeamsService } from './teams.service'; import { TeamsController } from './teams.controller'; import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Team, TeamMemberSplit, Bounty]), + // RolesGuard (used via @UseGuards on TeamsController) needs its own + // Repository resolvable in this module's DI scope — importing + // AuthModule alone isn't enough, since exporting a class from another + // module doesn't re-export that class's own constructor dependencies. + TypeOrmModule.forFeature([Team, TeamMemberSplit, Bounty, User]), AuthModule, ], controllers: [TeamsController], diff --git a/src/teams/teams.service.spec.ts b/src/teams/teams.service.spec.ts index c194956..e9fd213 100644 --- a/src/teams/teams.service.spec.ts +++ b/src/teams/teams.service.spec.ts @@ -21,10 +21,11 @@ describe('TeamsService', () => { }; splitRepo = { create: jest.fn((s: Partial) => s), - save: jest.fn((s: Partial | Partial[]) => - Array.isArray(s) - ? Promise.resolve(s.map((x) => ({ id: `split-${x.userId}`, ...x }))) - : Promise.resolve({ id: `split-${s.userId}`, ...s }), + save: jest.fn( + (s: Partial | Partial[]) => + Array.isArray(s) + ? Promise.resolve(s.map((x) => ({ id: `split-${x.userId}`, ...x }))) + : Promise.resolve({ id: `split-${s.userId}`, ...s }), ), delete: jest.fn().mockResolvedValue(undefined), }; @@ -52,7 +53,9 @@ describe('TeamsService', () => { name: 'Team A', members: [{ userId: 'u1', percentage: 60 }], }), - ).rejects.toThrow('Team split percentages must sum to 100, got 60.00'); + ).rejects.toThrow( + 'team member split percentages must sum to 100, got 60.00', + ); expect(teamRepo.save).not.toHaveBeenCalled(); }); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index b998e0a..adf46ec 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { IsString } from 'class-validator'; import { UsersService } from './users.service'; diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 9028995..ba1fe1a 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -141,7 +141,11 @@ describe('UsersService', () => { if (where?.username === 'octocat-1') return null; if (where?.email === 'octocat@example.com') return null; if (where?.id === 'u1') { - return { id: 'u1', username: 'octocat-1', githubAccount: { id: 'ga1' } }; + return { + id: 'u1', + username: 'octocat-1', + githubAccount: { id: 'ga1' }, + }; } return null; }); diff --git a/test/escrow-idempotency.e2e-spec.ts b/test/escrow-idempotency.e2e-spec.ts index 64d851b..b542070 100644 --- a/test/escrow-idempotency.e2e-spec.ts +++ b/test/escrow-idempotency.e2e-spec.ts @@ -8,6 +8,8 @@ import { EscrowService } from '../src/escrow/escrow.service'; import { IdempotencyKey } from '../src/common/entities/idempotency-key.entity'; import { IdempotencyInterceptor } from '../src/common/idempotency/idempotency.interceptor'; import { IdempotencyKeyStatus } from '../src/common/enums'; +import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../src/auth/guards/roles.guard'; /** * In-memory stand-in for Repository, matching the DB's @@ -108,7 +110,12 @@ describe('Escrow idempotency: cross-resource key reuse (#54)', () => { useValue: new FakeIdempotencyRepo(), }, ], - }).compile(); + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); diff --git a/test/mocks/stellar-sdk.mock.js b/test/mocks/stellar-sdk.mock.js index 410dcbf..a1a32f5 100644 --- a/test/mocks/stellar-sdk.mock.js +++ b/test/mocks/stellar-sdk.mock.js @@ -66,7 +66,7 @@ module.exports = { }, Api: { isSimulationError: () => false, - GetTransactionStatus: { NOT_FOUND: 'NOT_FOUND' }, + GetTransactionStatus: { NOT_FOUND: 'NOT_FOUND', SUCCESS: 'SUCCESS' }, }, assembleTransaction: (tx) => ({ build: () => tx }), }, diff --git a/test/stellar-address-validation-bounties-milestones.e2e-spec.ts b/test/stellar-address-validation-bounties-milestones.e2e-spec.ts index 188c8b6..1ffe7b6 100644 --- a/test/stellar-address-validation-bounties-milestones.e2e-spec.ts +++ b/test/stellar-address-validation-bounties-milestones.e2e-spec.ts @@ -12,6 +12,8 @@ import { MilestonesService } from '../src/milestones/milestones.service'; import { IdempotencyKeyStatus } from '../src/common/enums'; import { IdempotencyKey } from '../src/common/entities/idempotency-key.entity'; import { IdempotencyInterceptor } from '../src/common/idempotency/idempotency.interceptor'; +import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../src/auth/guards/roles.guard'; /** Same in-memory stand-in used across this directory's e2e specs — see * escrow-idempotency.e2e-spec.ts's FakeIdempotencyRepo for the rationale @@ -93,6 +95,12 @@ describe('Stellar address validation at the API boundary — bounties & mileston let app: INestApplication; let bountiesService: { fund: jest.Mock }; let milestonesService: { fund: jest.Mock; resolveIssue: jest.Mock }; + // Bounty/Milestone/Issue ids are real UUID columns (ParseUUIDPipe on each + // route) — not the human-readable placeholders this spec previously (and + // incorrectly) used as path params. + const bounty_1Id = randomUUID(); + const milestone_1Id = randomUUID(); + const issue_1Id = randomUUID(); beforeAll(async () => { bountiesService = { @@ -112,7 +120,12 @@ describe('Stellar address validation at the API boundary — bounties & mileston Reflector, newFakeRepoProvider(), ], - }).compile(); + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes( @@ -142,7 +155,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston 'rejects POST /bounties/:id/fund with a malformed funderAddress (%p) as 400', async (bad) => { await request(app.getHttpServer()) - .post('/bounties/bounty_1/fund') + .post(`/bounties/${bounty_1Id}/fund`) .set('Idempotency-Key', randomUUID()) .send({ funderAddress: bad }) .expect(400); @@ -153,7 +166,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston it('accepts POST /bounties/:id/fund with a valid funderAddress', async () => { await request(app.getHttpServer()) - .post('/bounties/bounty_1/fund') + .post(`/bounties/${bounty_1Id}/fund`) .set('Idempotency-Key', randomUUID()) .send({ funderAddress: Keypair.random().publicKey() }) .expect(201); @@ -165,7 +178,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston 'rejects POST /milestones/:id/fund with a malformed funderAddress (%p) as 400', async (bad) => { await request(app.getHttpServer()) - .post('/milestones/milestone_1/fund') + .post(`/milestones/${milestone_1Id}/fund`) .set('Idempotency-Key', randomUUID()) .send({ funderAddress: bad }) .expect(400); @@ -176,7 +189,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston it('accepts POST /milestones/:id/fund with a valid funderAddress', async () => { await request(app.getHttpServer()) - .post('/milestones/milestone_1/fund') + .post(`/milestones/${milestone_1Id}/fund`) .set('Idempotency-Key', randomUUID()) .send({ funderAddress: Keypair.random().publicKey() }) .expect(201); @@ -188,7 +201,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston 'rejects POST /milestones/:id/issues/:issueId/resolve with a malformed recipientAddress (%p) as 400', async (bad) => { await request(app.getHttpServer()) - .post('/milestones/milestone_1/issues/issue_1/resolve') + .post(`/milestones/${milestone_1Id}/issues/${issue_1Id}/resolve`) .set('Idempotency-Key', randomUUID()) .send({ recipientAddress: bad }) .expect(400); @@ -199,7 +212,7 @@ describe('Stellar address validation at the API boundary — bounties & mileston it('accepts POST /milestones/:id/issues/:issueId/resolve with a valid recipientAddress', async () => { await request(app.getHttpServer()) - .post('/milestones/milestone_1/issues/issue_1/resolve') + .post(`/milestones/${milestone_1Id}/issues/${issue_1Id}/resolve`) .set('Idempotency-Key', randomUUID()) .send({ recipientAddress: Keypair.random().publicKey() }) .expect(201); diff --git a/test/stellar-address-validation-escrow.e2e-spec.ts b/test/stellar-address-validation-escrow.e2e-spec.ts index 862a935..21a8dd4 100644 --- a/test/stellar-address-validation-escrow.e2e-spec.ts +++ b/test/stellar-address-validation-escrow.e2e-spec.ts @@ -10,6 +10,8 @@ import { EscrowService } from '../src/escrow/escrow.service'; import { AssetType, IdempotencyKeyStatus } from '../src/common/enums'; import { IdempotencyKey } from '../src/common/entities/idempotency-key.entity'; import { IdempotencyInterceptor } from '../src/common/idempotency/idempotency.interceptor'; +import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../src/auth/guards/roles.guard'; /** * Same in-memory stand-in as escrow-idempotency.e2e-spec.ts's @@ -92,6 +94,10 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# release: jest.Mock; splitRelease: jest.Mock; }; + // Escrow.id is a real UUID column (ParseUUIDPipe on the route) — not the + // human-readable 'esc_1' this spec previously (and incorrectly) used as a + // path param. + const esc_1Id = randomUUID(); beforeAll(async () => { escrowService = { @@ -111,7 +117,12 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# useValue: new FakeIdempotencyRepo(), }, ], - }).compile(); + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); app = moduleFixture.createNestApplication(); // Mirrors main.ts's ValidationPipe config exactly — this is what @@ -177,7 +188,7 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# 'rejects POST /escrow/:id/release with a malformed recipientAddress (%p) as 400, never reaching EscrowService', async (bad) => { await request(app.getHttpServer()) - .post('/escrow/esc_1/release') + .post(`/escrow/${esc_1Id}/release`) .set('Idempotency-Key', randomUUID()) .send({ recipientAddress: bad }) .expect(400); @@ -188,7 +199,7 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# it('accepts POST /escrow/:id/release with a valid recipientAddress', async () => { await request(app.getHttpServer()) - .post('/escrow/esc_1/release') + .post(`/escrow/${esc_1Id}/release`) .set('Idempotency-Key', randomUUID()) .send({ recipientAddress: Keypair.random().publicKey() }) .expect(201); @@ -198,7 +209,7 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# it('rejects POST /escrow/:id/split-release when any recipient in the array has a malformed recipientAddress', async () => { await request(app.getHttpServer()) - .post('/escrow/esc_1/split-release') + .post(`/escrow/${esc_1Id}/split-release`) .set('Idempotency-Key', randomUUID()) .send({ recipients: [ @@ -213,7 +224,7 @@ describe('Stellar address validation at the API boundary — escrow endpoints (# it('accepts POST /escrow/:id/split-release when every recipient has a valid recipientAddress', async () => { await request(app.getHttpServer()) - .post('/escrow/esc_1/split-release') + .post(`/escrow/${esc_1Id}/split-release`) .set('Idempotency-Key', randomUUID()) .send({ recipients: [ diff --git a/test/stellar-address-validation-maintenance-pool.e2e-spec.ts b/test/stellar-address-validation-maintenance-pool.e2e-spec.ts index 3590a05..1d5d332 100644 --- a/test/stellar-address-validation-maintenance-pool.e2e-spec.ts +++ b/test/stellar-address-validation-maintenance-pool.e2e-spec.ts @@ -85,6 +85,10 @@ function checksumInvalidAddress(): string { describe('Stellar address validation at the API boundary — maintenance-pool endpoints (#60)', () => { let app: INestApplication; let poolService: { deposit: jest.Mock; assignReward: jest.Mock }; + // MaintenancePool.id is a real UUID column (ParseUUIDPipe on the route) — + // not the human-readable 'pool_1' this spec previously (and incorrectly) + // used as a path param. + const poolId = randomUUID(); beforeAll(async () => { poolService = { @@ -137,7 +141,7 @@ describe('Stellar address validation at the API boundary — maintenance-pool en 'rejects POST /maintenance-pools/:id/deposit with a malformed funderAddress (%p) as 400', async (bad) => { await request(app.getHttpServer()) - .post('/maintenance-pools/pool_1/deposit') + .post(`/maintenance-pools/${poolId}/deposit`) .set('Idempotency-Key', randomUUID()) .send({ amount: '10.0000000', funderAddress: bad }) .expect(400); @@ -148,7 +152,7 @@ describe('Stellar address validation at the API boundary — maintenance-pool en it('accepts POST /maintenance-pools/:id/deposit with a valid funderAddress', async () => { await request(app.getHttpServer()) - .post('/maintenance-pools/pool_1/deposit') + .post(`/maintenance-pools/${poolId}/deposit`) .set('Idempotency-Key', randomUUID()) .send({ amount: '10.0000000', @@ -163,7 +167,7 @@ describe('Stellar address validation at the API boundary — maintenance-pool en 'rejects POST /maintenance-pools/:id/assign-reward with a malformed recipientAddress (%p) as 400', async (bad) => { await request(app.getHttpServer()) - .post('/maintenance-pools/pool_1/assign-reward') + .post(`/maintenance-pools/${poolId}/assign-reward`) .set('Idempotency-Key', randomUUID()) .send({ amount: '5.0000000', recipientAddress: bad }) .expect(400); @@ -174,7 +178,7 @@ describe('Stellar address validation at the API boundary — maintenance-pool en it('accepts POST /maintenance-pools/:id/assign-reward with a valid recipientAddress', async () => { await request(app.getHttpServer()) - .post('/maintenance-pools/pool_1/assign-reward') + .post(`/maintenance-pools/${poolId}/assign-reward`) .set('Idempotency-Key', randomUUID()) .send({ issueId: randomUUID(), diff --git a/test/users.e2e-spec.ts b/test/users.e2e-spec.ts index de717f6..8299db6 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 a guard-denied request (always 403) @@ -34,9 +32,7 @@ describe('UsersController (e2e)', () => { describe('GET /users', () => { it('should return 403 when the guard denies the request', () => { - return request(app.getHttpServer()) - .get('/users') - .expect(403); + return request(app.getHttpServer()).get('/users').expect(403); }); });