diff --git a/src/bounties/bounties.controller.spec.ts b/src/bounties/bounties.controller.spec.ts index 9fa19668..d42a9290 100644 --- a/src/bounties/bounties.controller.spec.ts +++ b/src/bounties/bounties.controller.spec.ts @@ -1,5 +1,7 @@ +import type { Request } from 'express'; import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { BountiesController } from './bounties.controller'; @@ -9,6 +11,7 @@ import { ClaimBountyDto } from './dto/claim-bounty.dto'; import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; describe('BountiesController', () => { let controller: BountiesController; @@ -47,7 +50,13 @@ describe('BountiesController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [BountiesController], - providers: [{ provide: BountiesService, useValue: bountiesService }], + providers: [ + { provide: BountiesService, useValue: bountiesService }, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() }, + }, + ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) @@ -68,9 +77,11 @@ describe('BountiesController', () => { difficulty: BountyDifficulty.INTERMEDIATE, }; - await controller.create(dto); + await controller.create(dto, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(bountiesService.create).toHaveBeenCalledWith(dto); + expect(bountiesService.create).toHaveBeenCalledWith(dto, 'sponsor-1'); }); }); @@ -84,15 +95,23 @@ describe('BountiesController', () => { describe('fund', () => { it('calls bountiesService.fund with id and funderAddress', async () => { - await controller.fund('b1', { funderAddress: 'GFUNDER' }); - - expect(bountiesService.fund).toHaveBeenCalledWith('b1', 'GFUNDER'); + await controller.fund('b1', { funderAddress: 'GFUNDER' }, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(bountiesService.fund).toHaveBeenCalledWith( + 'b1', + 'GFUNDER', + 'sponsor-1', + ); }); }); describe('claim', () => { - it('calls bountiesService.claim with id and contributorId', async () => { - await controller.claim('b1', { contributorId: 'contributor-1' }); + it('calls bountiesService.claim with id and the authenticated user id', async () => { + await controller.claim('b1', { + user: { userId: 'contributor-1' }, + } as unknown as Request); expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1'); }); @@ -116,9 +135,14 @@ describe('BountiesController', () => { describe('refund', () => { it('calls bountiesService.refund with the route param', async () => { - await controller.refund('b1'); + await controller.refund('b1', { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(bountiesService.refund).toHaveBeenCalledWith('b1'); + expect(bountiesService.refund).toHaveBeenCalledWith( + 'b1', + 'sponsor-1', + ); }); }); diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 38ad22e1..7bffa64f 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -89,6 +89,7 @@ export class BountiesController { @ApiOperation({ summary: 'Get a bounty by id' }) @ApiStandardErrorResponses() + @Throttle({ long: { limit: 1000, ttl: 3600000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.bountiesService.findOne(id); diff --git a/src/bounties/bounties.service.spec.ts b/src/bounties/bounties.service.spec.ts index 2450d718..0a68a6ff 100644 --- a/src/bounties/bounties.service.spec.ts +++ b/src/bounties/bounties.service.spec.ts @@ -1,9 +1,10 @@ +import { BadRequestException, NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { BountiesService } from './bounties.service'; import { EscrowService } from '../escrow/escrow.service'; -import { Bounty, Team, User } from '../common/entities'; +import { Bounty, Issue, Team, User } from '../common/entities'; import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; import { InvalidBountyTransitionError } from './bounty-state-machine'; @@ -18,6 +19,7 @@ describe('BountiesService', () => { }; let userRepo: { findOne: jest.Mock; find: jest.Mock }; let teamRepo: { findOne: jest.Mock }; + let issueRepo: { findOne: jest.Mock }; let escrowService: { fund: jest.Mock; release: jest.Mock; @@ -39,6 +41,7 @@ describe('BountiesService', () => { }; userRepo = { findOne: jest.fn(), find: jest.fn() }; teamRepo = { findOne: jest.fn() }; + issueRepo = { findOne: jest.fn().mockResolvedValue({ id: 'issue-1' }) }; escrowService = { fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }), release: jest.fn().mockResolvedValue(undefined), @@ -52,6 +55,7 @@ describe('BountiesService', () => { { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, { provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(Team), useValue: teamRepo }, + { provide: getRepositoryToken(Issue), useValue: issueRepo }, { provide: EscrowService, useValue: escrowService }, { provide: EventEmitter2, useValue: { emit: jest.fn() } }, ], @@ -61,13 +65,17 @@ describe('BountiesService', () => { }); it('creates a bounty in OPEN status', async () => { - const bounty = await service.create({ - issueId: 'issue-1', - sponsorId: 'sponsor-1', - amount: '100', - asset: AssetType.USDC, - difficulty: BountyDifficulty.INTERMEDIATE, - }); + userRepo.findOne.mockResolvedValue({ id: 'sponsor-1' }); + const bounty = await service.create( + { + issueId: 'issue-1', + sponsorId: 'sponsor-1', + amount: '100', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }, + 'sponsor-1', + ); expect(bounty.status).toBe(BountyStatus.OPEN); }); @@ -80,7 +88,7 @@ describe('BountiesService', () => { sponsorId: 'sponsor-1', }); - const bounty = await service.fund('b1', 'GFUNDER'); + const bounty = await service.fund('b1', 'GFUNDER', 'sponsor-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -104,7 +112,7 @@ describe('BountiesService', () => { issue: { githubIssueId: '2891234567' }, }); - await service.fund('b1', 'GFUNDER'); + await service.fund('b1', 'GFUNDER', 'sponsor-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -118,8 +126,9 @@ describe('BountiesService', () => { bountyRepo.findOne.mockResolvedValue({ id: 'b1', status: BountyStatus.FUNDED, + sponsorId: 'sponsor-1', }); - await expect(service.fund('b1', 'GFUNDER')).rejects.toThrow( + await expect(service.fund('b1', 'GFUNDER', 'sponsor-1')).rejects.toThrow( InvalidBountyTransitionError, ); }); @@ -134,16 +143,47 @@ describe('BountiesService', () => { ); }); - it('claim moves FUNDED -> CLAIMED and records the contributor', async () => { + it('claim moves FUNDED -> CLAIMED and records the caller as claimant', async () => { bountyRepo.findOne.mockResolvedValue({ id: 'b1', status: BountyStatus.FUNDED, }); + userRepo.findOne.mockResolvedValue({ + id: 'contributor-1', + stellarAddress: 'GCONTRIB', + }); const bounty = await service.claim('b1', 'contributor-1'); + expect(userRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'contributor-1' }, + }); expect(bounty.status).toBe(BountyStatus.CLAIMED); expect(bounty.claimedById).toBe('contributor-1'); }); + it('claim rejects a caller that is not a known user', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.FUNDED, + }); + userRepo.findOne.mockResolvedValue(null); + await expect(service.claim('b1', 'ghost')).rejects.toThrow( + BadRequestException, + ); + }); + + it('claim rejects a caller without a linked Stellar address', async () => { + const stored = { id: 'b1', status: BountyStatus.FUNDED, claimedById: null }; + bountyRepo.findOne.mockResolvedValue(stored); + userRepo.findOne.mockResolvedValue({ + id: 'contributor-1', + stellarAddress: null, + }); + await expect(service.claim('b1', 'contributor-1')).rejects.toThrow( + BadRequestException, + ); + expect(stored.claimedById).toBeNull(); + }); + it('markMergedAndRelease releases escrow to the contributor and moves to PAID', async () => { bountyRepo.findOne.mockResolvedValue({ id: 'b1', @@ -315,3 +355,18 @@ describe('BountiesService', () => { ); }); }); + +// Standalone: approve/reject touch no dependencies, so they're tested without +// the shared TestingModule above. +describe('BountiesService approve / reject (#533)', () => { + const service = Object.create(BountiesService.prototype) as BountiesService; + + it('answer 501 instead of reporting a fake approval or rejection', async () => { + await expect(service.approve('b1')).rejects.toBeInstanceOf( + NotImplementedException, + ); + await expect(service.reject('b1')).rejects.toBeInstanceOf( + NotImplementedException, + ); + }); +}); diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index 32b3dba0..3a6ce936 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, Optional, } from '@nestjs/common'; -import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, NotImplementedException, Optional } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -106,22 +106,21 @@ export class BountiesService { assertTransition(bounty.status, BountyStatus.CLAIMED); // Verify caller is claiming for themselves - bounty.claimedById = callerUserId; const contributor = await this.userRepo.findOne({ - where: { id: contributorId }, + where: { id: callerUserId }, }); if (!contributor) { throw new BadRequestException( - `Contributor ${contributorId} does not correspond to a known user`, + `Contributor ${callerUserId} does not correspond to a known user`, ); } if (!contributor.stellarAddress) { throw new BadRequestException( - `Contributor ${contributorId} has no linked Stellar address`, + `Contributor ${callerUserId} has no linked Stellar address`, ); } - bounty.claimedById = contributorId; + bounty.claimedById = callerUserId; bounty.status = BountyStatus.CLAIMED; bounty.claimedAt = new Date(); return this.bountyRepo.save(bounty); @@ -295,11 +294,26 @@ export class BountiesService { return qb.getMany(); } - approve(id: string) { - return Promise.resolve({ id, status: 'approved' }); + /** + * Not implemented yet (#253, #533). This used to resolve + * `{ id, status: 'approved' }` without reading or writing anything, so the + * route reported success for an approval that never happened. It now + * answers 501 until the real approval flow exists. + */ + approve(id: string): Promise { + return Promise.reject( + new NotImplementedException( + `Bounty approval is not implemented yet (bounty ${id})`, + ), + ); } - reject(id: string) { - return Promise.resolve({ id, status: 'rejected' }); + /** Not implemented yet (#253, #533) — see `approve`. */ + reject(id: string): Promise { + return Promise.reject( + new NotImplementedException( + `Bounty rejection is not implemented yet (bounty ${id})`, + ), + ); } } diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 6d2dd289..c252993f 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -584,20 +584,14 @@ export class EscrowService { recipients: Array<[string, number]>, manager?: EntityManager, ): Promise { - return this.invokeOnLockedEscrow(escrow, operation, () => - this.soroban.invoke( - 'release', - // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). - [u64(this.onChainKeyFor(escrow)), recipients], - this.contractOpts(escrow), - ), return this.invokeOnLockedEscrow( escrow, operation, () => this.soroban.invoke( 'release', - [this.onChainKeyFor(escrow), recipients], + // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). + [u64(this.onChainKeyFor(escrow)), recipients], this.contractOpts(escrow), ), manager, diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index 45bb6fe8..a35ac3be 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -869,6 +869,9 @@ describe('GithubWebhooksService', () => { expect(bountiesService.markInReview).not.toHaveBeenCalled(); expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled(); + }); + }); + // #308: a redelivered X-GitHub-Delivery used to hit the unique constraint // on webhook_events.deliveryId and escape handleEvent as a 500, so every // redelivery of that event failed forever. diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index 0b370c4c..a38b48bd 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -81,6 +81,9 @@ export class MaintenancePoolController { } @ApiOperation({ summary: 'List maintenance pools' }) + // Public read returning the whole table — tighten past the global default + // so it can't be scraped at the lenient rate (#592). + @Throttle({ long: { limit: 300, ttl: 3600000 } }) @Get() list() { return this.poolService.list(); @@ -88,6 +91,7 @@ export class MaintenancePoolController { @ApiOperation({ summary: 'Get a maintenance pool by id' }) @ApiStandardErrorResponses() + @Throttle({ long: { limit: 1000, ttl: 3600000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.poolService.findOne(id); diff --git a/src/maintenance-pool/maintenance-pool.service.spec.ts b/src/maintenance-pool/maintenance-pool.service.spec.ts index 88ff0c4a..87dc5a27 100644 --- a/src/maintenance-pool/maintenance-pool.service.spec.ts +++ b/src/maintenance-pool/maintenance-pool.service.spec.ts @@ -302,6 +302,7 @@ describe('MaintenancePoolService', () => { it('rejects when the pool has no funded escrow yet', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: null, }); @@ -315,6 +316,7 @@ describe('MaintenancePoolService', () => { it('rejects when the requested amount exceeds the pool balance', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '50', escrowId: 'escrow-1', }); @@ -337,6 +339,7 @@ describe('MaintenancePoolService', () => { it('releases the reward and atomically decrements the balance', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: 'escrow-1', }); @@ -373,6 +376,7 @@ describe('MaintenancePoolService', () => { it('rejects a reward for a non-maintenance issue before releasing funds', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: 'escrow-1', }); @@ -391,6 +395,7 @@ describe('MaintenancePoolService', () => { it('rejects an issue outside the pool repository', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, repositoryId: 'repository-1', balance: '100', escrowId: 'escrow-1', @@ -410,6 +415,7 @@ describe('MaintenancePoolService', () => { it('rejects when the issue has already received a reward from this pool (#273)', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: 'escrow-1', }); @@ -437,9 +443,14 @@ describe('MaintenancePoolService', () => { // time — not a value cached from an earlier read — and neither // decrement is lost. it('two concurrent assignReward calls both apply — no lost decrement (#51)', async () => { - const sharedPoolRow: { balance: string; escrowId: string } = { + const sharedPoolRow: { + balance: string; + escrowId: string; + status: MaintenancePoolStatus; + } = { balance: '1000.0000000', escrowId: 'escrow-1', + status: MaintenancePoolStatus.ACTIVE, }; poolRepo.findOne.mockImplementation(() => Promise.resolve({ id: 'pool-1', ...sharedPoolRow }), diff --git a/src/milestones/milestones.controller.spec.ts b/src/milestones/milestones.controller.spec.ts index 8d87497d..06a90603 100644 --- a/src/milestones/milestones.controller.spec.ts +++ b/src/milestones/milestones.controller.spec.ts @@ -1,3 +1,4 @@ +import type { Request } from 'express'; import { Test, TestingModule } from '@nestjs/testing'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; @@ -5,8 +6,10 @@ import { MilestonesController } from './milestones.controller'; import { MilestonesService } from './milestones.service'; import { CreateMilestoneDto } from './dto/create-milestone.dto'; import { AssetType, MilestoneStatus } from '../common/enums'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; describe('MilestonesController', () => { let controller: MilestonesController; @@ -33,7 +36,13 @@ describe('MilestonesController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MilestonesController], - providers: [{ provide: MilestonesService, useValue: milestonesService }], + providers: [ + { provide: MilestonesService, useValue: milestonesService }, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() }, + }, + ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) @@ -53,9 +62,14 @@ describe('MilestonesController', () => { asset: AssetType.USDC, }; - await controller.create(dto); + await controller.create(dto, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(milestonesService.create).toHaveBeenCalledWith(dto); + expect(milestonesService.create).toHaveBeenCalledWith( + dto, + 'sponsor-1', + ); }); }); @@ -77,9 +91,15 @@ describe('MilestonesController', () => { describe('fund', () => { it('calls milestonesService.fund with id and funderAddress', async () => { - await controller.fund('m1', { funderAddress: 'GFUNDER' }); - - expect(milestonesService.fund).toHaveBeenCalledWith('m1', 'GFUNDER'); + await controller.fund('m1', { funderAddress: 'GFUNDER' }, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(milestonesService.fund).toHaveBeenCalledWith( + 'm1', + 'GFUNDER', + 'sponsor-1', + ); }); }); diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d99..851f7648 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -76,6 +76,7 @@ export class MilestonesController { } @ApiOperation({ summary: 'Get a milestone by id' }) + @Throttle({ long: { limit: 1000, ttl: 3600000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.milestonesService.findOne(id); diff --git a/src/milestones/milestones.service.spec.ts b/src/milestones/milestones.service.spec.ts index 612bde8d..e851e8a9 100644 --- a/src/milestones/milestones.service.spec.ts +++ b/src/milestones/milestones.service.spec.ts @@ -6,6 +6,7 @@ import { MilestonesService } from './milestones.service'; import { EscrowService } from '../escrow/escrow.service'; import { Issue, Milestone } from '../common/entities'; import { AssetType, MilestoneStatus } from '../common/enums'; +import { InvalidMilestoneTransitionError } from './milestone-state-machine'; describe('MilestonesService', () => { let service: MilestonesService; @@ -75,7 +76,7 @@ describe('MilestonesService', () => { sponsorId: 'sponsor-1', }); - const milestone = await service.fund('m1', 'GFUNDER'); + const milestone = await service.fund('m1', 'GFUNDER', 'sponsor-1'); expect(milestone.status).toBe(MilestoneStatus.FUNDED); expect(milestone.escrowId).toBe('escrow-1'); @@ -90,7 +91,7 @@ describe('MilestonesService', () => { sponsorId: 'sponsor-1', }); - await service.fund('m1', 'GFUNDER'); + await service.fund('m1', 'GFUNDER', 'sponsor-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -110,7 +111,7 @@ describe('MilestonesService', () => { sponsorId: null, }); - await service.fund('m2', 'GFUNDER'); + await service.fund('m2', 'GFUNDER', 'sponsor-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ sponsorId: null }), @@ -123,7 +124,7 @@ describe('MilestonesService', () => { status: MilestoneStatus.FUNDED, }); - await expect(service.fund('m1', 'GFUNDER')).rejects.toThrow( + await expect(service.fund('m1', 'GFUNDER', 'sponsor-1')).rejects.toThrow( 'Milestone m1 is not OPEN (current: funded)', ); }); @@ -263,6 +264,7 @@ describe('MilestonesService', () => { '250.0000000', 'RECIPIENT_ADDR', 'user-1', + expect.anything(), ); expect(payment.id).toBe('payment-1'); expect(dataSource.transaction).toHaveBeenCalled(); @@ -341,7 +343,7 @@ describe('MilestonesService', () => { await expect( service.resolveIssue('m1', 'i1', 'RECIPIENT_ADDR'), - ).rejects.toThrow('Milestone m1 is not accepting distributions'); + ).rejects.toThrow(InvalidMilestoneTransitionError); }); it('rejects when the issue does not belong to the milestone (#114)', async () => { @@ -406,6 +408,7 @@ describe('MilestonesService', () => { '333.3333333', 'RECIPIENT_ADDR', undefined, + expect.anything(), ); }); }); diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index b7a48cca..c097bf1e 100644 --- a/src/sponsors/sponsors.controller.ts +++ b/src/sponsors/sponsors.controller.ts @@ -26,25 +26,6 @@ function validatePage(limit?: number, offset?: number) { throw new BadRequestException('offset must be >= 0'); } return { limit, offset }; -/** - * Parse optional limit/offset query params, rejecting NaN, negatives, - * non-integers, and limits above MAX_PAGE_LIMIT with a 400 (#280). - */ -function parsePagination(limit?: string, offset?: string) { - const parse = (name: string, raw: string | undefined, min: number, max: number) => { - if (raw === undefined || raw === '') return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n < min || n > max) { - throw new BadRequestException( - `${name} must be an integer between ${min} and ${max}`, - ); - } - return n; - }; - return { - limit: parse('limit', limit, 1, MAX_PAGE_LIMIT), - offset: parse('offset', offset, 0, Number.MAX_SAFE_INTEGER), - }; } @ApiTags('sponsors') @@ -73,7 +54,6 @@ export class SponsorsController { ) { this.assertOwnsSponsor(user, id); return this.sponsorsService.dashboard(id, validatePage(limit, offset)); - return this.sponsorsService.dashboard(id, parsePagination(limit, offset)); } @ApiBearerAuth() @@ -91,6 +71,5 @@ export class SponsorsController { id, validatePage(limit, offset), ); - return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset)); } } diff --git a/test/mocks/stellar-sdk.mock.js b/test/mocks/stellar-sdk.mock.js index a1a32f55..80be0ef7 100644 --- a/test/mocks/stellar-sdk.mock.js +++ b/test/mocks/stellar-sdk.mock.js @@ -40,7 +40,56 @@ class TransactionBuilder { } } +// Faithful StrKey check for Ed25519 public keys: base32 decode, version +// byte 6<<3 ('G'), 35-byte frame, CRC16-XModem trailer — the same algorithm +// the real SDK applies, kept here so specs can exercise checksum-invalid +// addresses without dragging in the ESM-only dependency tree. +const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const VERSION_ACCOUNT_ID = 6 << 3; + +function crc16Xmodem(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte << 8; + for (let i = 0; i < 8; i += 1) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; +} + +function base32Decode(str) { + let bits = 0; + let value = 0; + const out = []; + for (const ch of str) { + const idx = B32_ALPHABET.indexOf(ch); + if (idx === -1) return null; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return out; +} + +const StrKey = { + isValidEd25519PublicKey(value) { + if (typeof value !== 'string' || value.length !== 56 || value[0] !== 'G') { + return false; + } + const decoded = base32Decode(value); + if (!decoded || decoded.length !== 35) return false; + if (decoded[0] !== VERSION_ACCOUNT_ID) return false; + const expected = crc16Xmodem(decoded.slice(0, 33)); + return decoded[33] === (expected & 0xff) && decoded[34] === (expected >> 8); + }, +}; + module.exports = { + StrKey, Contract, Address, Keypair,