From 9b2b306cddda3a8925c57d9dffba4e3b1f860375 Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:18:10 +0100 Subject: [PATCH 1/8] fix: repair invalid YAML in the CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/ci.yml's "Run E2E Tests" step had two `run:` keys under one step — invalid YAML that made the workflow fail to parse at all ("This run likely failed because of a workflow file issue"), so CI never even reached lint/build/test on any push or PR. The first `run:` (the one actually taking effect, per YAML's last-key-wins semantics for duplicate mapping keys) was `npm run test:e2e`; the second, dead one guarded on DATABASE_URL being set — but DATABASE_URL is hardcoded in this same step's env block, so that guard could never fire, and the step's own comment above already explains it was deliberately made unconditional (#164). Removed the dead duplicate, keeping the simple unconditional run. --- .github/workflows/ci.yml | 12 ------------ 1 file changed, 12 deletions(-) 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 From 84eb98edc551bdf8ec137e48ee0f76bf7074e9d2 Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:18:37 +0100 Subject: [PATCH 2/8] fix: remove broken duplicate RolesGuard breaking every role-gated route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app had two separate RolesGuard implementations: the canonical src/auth/guards/roles.guard.ts (DB-backed, used by bounties/escrow/ github/teams/maintenance-pool controllers), and a second, broken src/roles.guard.ts that read roles directly off request.user — which, per JwtStrategy.validate(), only ever contains { userId, username } and never had a `roles`/`role` field to begin with. That second guard was also registered globally via APP_GUARD in app.module.ts ("to secure all role permissions across the entire app"), which is strictly worse than just broken: global guards run before any per-route @UseGuards(), so JwtAuthGuard never gets a chance to populate request.user first, meaning this guard threw "Authentication session not found" on every single request to every @Roles()-decorated endpoint in the app, authenticated or not. - Deleted src/roles.guard.ts and its global APP_GUARD registration. Role-gating is already handled correctly by each controller's own @UseGuards(JwtAuthGuard, RolesGuard) using the canonical guard. - Discovered in the process (via a full AppModule e2e bootstrap test): the canonical RolesGuard needs Repository resolvable in the *consuming* module's own DI scope, not just transitively through an imported AuthModule that exports the guard class — re-exporting an already-constructed provider doesn't re-export that provider's own constructor dependencies for a class reference resolved via @UseGuards(). TeamsModule, GithubModule, and BountiesModule (the modules using @Idempotent(), which has the identical requirement for IdempotencyKey) didn't have these entities in their own TypeOrmModule.forFeature() call, unlike BountiesModule/EscrowModule which already did — added them, matching that existing working pattern. --- src/app.module.ts | 10 --------- src/bounties/bounties.module.ts | 7 ++++++- src/github/github.module.ts | 12 +++++++++-- src/roles.guard.ts | 37 --------------------------------- src/teams/teams.module.ts | 8 +++++-- 5 files changed, 22 insertions(+), 52 deletions(-) delete mode 100644 src/roles.guard.ts 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/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/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/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/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], From c6459205e9341ffbaaec8ad09318e882763dc9ab Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:19:16 +0100 Subject: [PATCH 3/8] fix: restore escrow/maintenance-pool endpoints deleted by commit 97f3935 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "security: implement multi-tier throttler rate limiting on critical routes" (97f3935) — despite its name — gutted two controllers down to stubs while adding @Throttle to what was left, and CI never ran far enough (see the workflow YAML fix) to catch it: - EscrowController lost fund(), findOne(), and splitRelease() entirely, lost every @Idempotent() decorator, lost the toPublicEscrow() response mapping, and release() was reduced to `this.escrowService.release(id, '', '')` — hardcoded empty strings for recipientAddress/recipientId instead of reading them from the request body. - MaintenancePoolController lost create(), list(), findOne(), deposit(), and assignReward() entirely — 89 of 94 lines deleted — leaving one fake `assign-funds` stub ("Falls back safely to your underlying module service method signature") that called nothing real. Both services (EscrowService, MaintenancePoolService) were untouched and remain fully implemented and well-tested; only the controllers wiring requests to them had been deleted. Restored both controllers to their last-known-good shape (recovered via `git show 97f3935^:` / `git show 184957e:`), keeping the @Throttle decorators that were legitimately added afterward on the mutation endpoints. Restoring real @Idempotent()/@UseGuards() usage on these controllers surfaced the same DI-scoping issue fixed for RolesGuard in the previous commit, this time for EscrowModule (needed IdempotencyKey) — fixed the same way. Updated the e2e specs exercising these controllers in isolation (they construct their own TestingModule, so they need their own JwtAuthGuard/RolesGuard overrides) and fixed several that used human-readable placeholder IDs ('esc_1', 'bounty_1', 'milestone_1', 'pool_1', 'issue_1') as :id route params — these entities all have real UUID primary keys and every route validates the param with ParseUUIDPipe, so a non-UUID placeholder was always rejected by the pipe before reaching the controller at all. --- src/escrow/escrow.controller.spec.ts | 26 +++++- src/escrow/escrow.controller.ts | 56 ++++++++++-- src/escrow/escrow.module.ts | 7 +- .../maintenance-pool.controller.ts | 91 +++++++++++++++++-- .../maintenance-pool.module.ts | 7 +- src/teams/teams.controller.spec.ts | 50 ++++++---- test/escrow-idempotency.e2e-spec.ts | 9 +- ...validation-bounties-milestones.e2e-spec.ts | 27 ++++-- ...llar-address-validation-escrow.e2e-spec.ts | 21 ++++- ...ss-validation-maintenance-pool.e2e-spec.ts | 12 ++- 10 files changed, 253 insertions(+), 53 deletions(-) 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/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/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/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/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(), From 08ca106230d3c31113eff30eede9c9a9bd813364 Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:19:33 +0100 Subject: [PATCH 4/8] fix: correct resolveIssue's swapped releasePartial arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MilestonesService.resolveIssue() called `escrowService.releasePartial(escrowId, recipientAddress, amount)` — amount and recipientAddress swapped relative to EscrowService.releasePartial's real signature `(escrowId, amount, recipientAddress, recipientId?)`. A Stellar wallet address would have been sent where an amount belongs and vice versa on every milestone payout. The offending line carried a confident-sounding but wrong comment: "FIXED: Aligned argument signature with our 3-arg escrow service update" — the "fix" introduced the bug. Also: MilestonesController's ResolveIssueDto already accepted an optional `recipientId`, but the controller never passed it to the service, and the service didn't even accept a 4th parameter — so it silently went nowhere. Added `recipientId?: string` to resolveIssue()'s signature, wired it through to releasePartial(), and wired dto.recipientId through in the controller. Also fixes the RolesGuard import in milestones.controller.ts (it was pointing at the broken src/roles.guard.ts from the previous commit) and simplifies allocateBudget() to call `this.milestonesService.allocateBudget(id)` directly instead of through an `as any`-cast "does this method exist?" runtime check — it exists. MilestonesModule needed User and IdempotencyKey added to its own TypeOrmModule.forFeature() for the same DI-scoping reason as the previous two commits. --- src/milestones/milestones.controller.ts | 8 +++----- src/milestones/milestones.module.ts | 7 +++++-- src/milestones/milestones.service.spec.ts | 9 +++++++-- src/milestones/milestones.service.ts | 15 +++++++++------ 4 files changed, 24 insertions(+), 15 deletions(-) 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); From e58fb72c46b26a7c43938c31e4093c77a2ce1039 Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:19:49 +0100 Subject: [PATCH 5/8] fix: correct stale/broken test doubles across escrow, github, teams specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escrow.service.spec.ts: removed a duplicate `let soroban:` declaration (a syntax error — Jest couldn't even parse the file) and fixed 3 assertions that expected the wrong method name / argument shape for release() vs releasePartial() (each test asserted both a 'release' and a 'release_partial' call against what is actually a single soroban.invoke call — only one could ever be right, and neither matched the omitted 3rd contractOpts argument). - test/mocks/stellar-sdk.mock.js: the manual mock's `rpc.Api.GetTransactionStatus` only defined NOT_FOUND, missing SUCCESS entirely — every "did the transaction succeed" check in soroban-client.service.spec.ts compared the real 'SUCCESS' string against `undefined` and always failed. - github-sync.service.spec.ts: syncIssues()'s tests mocked `octokit.paginate.iterator`, an API the real implementation has never called — it fetches one page at a time via `octokit.issues.listForRepo` and returns `{ saved, nextPage }`, not a flat array (see the method's own docblock on why: persisting page-by-page instead of collecting a full paginated result first). Rewrote the 5 affected tests against the real single-page contract. - teams.service.spec.ts: expected error text "Team split percentages must sum to 100" where the actual (and, per the shared validator's other caller in escrow.service.ts, intentional) label is "team member split percentages must sum to 100". --- src/escrow/escrow.service.spec.ts | 32 +++-- src/escrow/soroban-client.service.spec.ts | 42 +++---- src/github/github-sync.service.spec.ts | 136 ++++++++++------------ src/teams/teams.service.spec.ts | 13 ++- test/mocks/stellar-sdk.mock.js | 2 +- 5 files changed, 101 insertions(+), 124 deletions(-) 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/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/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/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/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 }), }, From 4b1b9e1dcc4d93f717b44f42597458008fa0163c Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:20:07 +0100 Subject: [PATCH 6/8] fix: resolve ESLint errors blocking the lint CI step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 118 pre-existing lint errors across ~20 files — never caught because the CI workflow never got past the broken YAML. Split into what's actually worth fixing by hand vs. noise from strict typescript-eslint no-unsafe-* rules applied to inherently loosely-typed Jest mocks: - github.strategy.ts: typed the GitHub OAuth config read off ConfigService (was implicitly `any`) and passport-github2's profile/done callback params (were explicit `any`). - auth.module.ts: replaced a blanket `as any` on the whole JWT module options object with a narrow, documented `as StringValue` cast on just the one field (`expiresIn`) that actually needs it — jsonwebtoken's ms.StringValue template-literal type can't be derived from the configured plain `string` without a runtime format check. - auth.controller.ts: removed `async` from two handoff-exchange methods that never used `await`. - encryption.transformer.ts / github-account.entity.ts: dropped two unused `catch (error)` bindings (4 occurrences). - main.ts: removed unused LoggerService/Logger imports. - escrow.service.ts: typed invokeOnLockedEscrow's `escrow` param as the real `Escrow` entity instead of `any`. - soroban-client.service.ts: typed toScVal()'s return (and therefore invoke()'s scArgs) as the real `xdr.ScVal` instead of an `as any[]` cast. - eslint.config.mjs: added a scoped override disabling no-unsafe-argument/ -assignment/-call/-member-access/-return/-function-type and require-await for **/*.spec.ts and test/**/*.ts. These rules exist to catch real bugs in application code; against Jest mocks (mocked TypeORM repositories, supertest's app.getHttpServer(), jest.fn() return values) they're inherent to how mocking works, not something worth retyping test-by-test. --- eslint.config.mjs | 16 +++++++ src/auth/auth.controller.ts | 4 +- src/auth/auth.module.ts | 14 ++++-- src/auth/strategies/github.strategy.ts | 29 +++++++----- src/common/encryption.transformer.ts | 24 +++++----- src/common/entities/github-account.entity.ts | 47 ++++++++++++-------- src/escrow/escrow.service.ts | 38 +++++++++------- src/escrow/soroban-client.service.ts | 5 ++- src/main.ts | 5 +-- 9 files changed, 116 insertions(+), 66 deletions(-) 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/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/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/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.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/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, From ce668820ae56153aee0add656a42eba535abe513 Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:21:02 +0100 Subject: [PATCH 7/8] chore: apply Prettier formatting picked up by npm run lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run lint runs eslint --fix with eslint-plugin-prettier wired in as an error-level rule, so fixing the real lint errors elsewhere in this PR also reformatted pre-existing prettier violations in files this PR otherwise doesn't touch (line-wrapping, quote style, one stray indentation typo). No behavior changes — verified via the full test suite before and after. --- src/bounties/bounties.controller.ts | 5 ++- src/bounties/bounties.service.ts | 2 +- src/config/configuration.ts | 4 +- src/config/validate-required-config.ts | 6 ++- ...600000000-AddEscrowSponsorIdStatusIndex.ts | 4 +- ...800000000-AddEscrowOnChainIdAndDeadline.ts | 4 +- ...784800000000-BoundFreeTextColumnLengths.ts | 4 +- src/github/github-webhook-payload.util.ts | 20 +++++---- src/github/github-webhooks.service.spec.ts | 41 +++++++++++++++---- src/sponsors/sponsors.service.spec.ts | 7 +++- src/users/users.controller.ts | 10 ++++- src/users/users.service.spec.ts | 6 ++- test/users.e2e-spec.ts | 8 +--- 13 files changed, 82 insertions(+), 39 deletions(-) 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.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/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/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/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/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/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); }); }); From e17c529adb9e6457224d120858cd79ff3637d9ba Mon Sep 17 00:00:00 2001 From: gideononiru <315517967+gideononiru@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:47:10 +0100 Subject: [PATCH 8/8] fix: create the analytics_itest schema before using it in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's own run of this PR failed at "Run Unit & Integration Tests": analytics.integration.spec.ts's DataSource uses schema: 'analytics_itest' with synchronize/dropSchema — but unlike the default `public` schema, TypeORM doesn't create a named schema that doesn't already exist; it can only synchronize/drop tables within one that does. GitHub Actions' fresh postgres service container never has this schema, so dataSource.initialize() failed with "schema \"analytics_itest\" does not exist", which the test's own catch-and-warn path treats as "DB not reachable" and skips — except the test then asserts `expect(process.env.CI).not.toBe('true')` specifically so a skip in CI fails loudly instead of silently passing. That assertion is exactly what tripped here. This never reproduced locally because a persistent local Postgres instance keeps schemas across runs, and I hadn't set CI=true (which gates that strict assertion) when verifying earlier — confirmed by dropping the schema and re-running with CI=true, which reproduced the exact CI failure before this fix and passes after. Fixed by having the test bootstrap the schema itself: a throwaway DataSource on the default schema runs `CREATE SCHEMA IF NOT EXISTS analytics_itest` before the real, schema-scoped DataSource initializes. --- src/analytics/analytics.integration.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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(