diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 85465180..197995de 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({ default: { limit: 120, ttl: 60000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.bountiesService.findOne(id); diff --git a/src/common/validators/money.validator.spec.ts b/src/common/validators/money.validator.spec.ts new file mode 100644 index 00000000..ce28bf80 --- /dev/null +++ b/src/common/validators/money.validator.spec.ts @@ -0,0 +1,101 @@ +import { + isValidMoneyAmount, + amountToStroops, + stroopsToAmount, + isSupportedEscrowAsset, + MAX_MONETARY_AMOUNT, +} from './money.validator'; +import { AssetType } from '../enums'; + +describe('money.validator', () => { + describe('isValidMoneyAmount', () => { + it('accepts valid positive amounts within 7 decimals', () => { + expect(isValidMoneyAmount('1')).toBe(true); + expect(isValidMoneyAmount('100.5')).toBe(true); + expect(isValidMoneyAmount('0.0000001')).toBe(true); + expect(isValidMoneyAmount('99.1234567')).toBe(true); + expect(isValidMoneyAmount(MAX_MONETARY_AMOUNT)).toBe(true); + }); + + it('rejects 8 decimal places', () => { + expect(isValidMoneyAmount('0.00000001')).toBe(false); + expect(isValidMoneyAmount('1.12345678')).toBe(false); + }); + + it('rejects 0 and non-positive numbers', () => { + expect(isValidMoneyAmount('0')).toBe(false); + expect(isValidMoneyAmount('0.0')).toBe(false); + expect(isValidMoneyAmount('0.0000000')).toBe(false); + expect(isValidMoneyAmount('-1')).toBe(false); + expect(isValidMoneyAmount('-0.5')).toBe(false); + }); + + it('rejects values exceeding the 100,000,000 cap', () => { + expect(isValidMoneyAmount('100000000.0000001')).toBe(false); + expect(isValidMoneyAmount('100000001')).toBe(false); + }); + + it('rejects numbers with illegal leading zeros', () => { + expect(isValidMoneyAmount('01')).toBe(false); + expect(isValidMoneyAmount('01.5')).toBe(false); + expect(isValidMoneyAmount('00.123')).toBe(false); + }); + + it('rejects non-strings and malformed numeric strings', () => { + expect(isValidMoneyAmount(123)).toBe(false); + expect(isValidMoneyAmount(null)).toBe(false); + expect(isValidMoneyAmount(undefined)).toBe(false); + expect(isValidMoneyAmount('')).toBe(false); + expect(isValidMoneyAmount('abc')).toBe(false); + expect(isValidMoneyAmount('1.2.3')).toBe(false); + }); + }); + + describe('amountToStroops and stroopsToAmount', () => { + it('accurately converts amount to stroops and back', () => { + const amount = '123.4567000'; + const stroops = amountToStroops(amount); + expect(stroops).toBe(1234567000n); + + const roundTrip = stroopsToAmount(stroops); + expect(roundTrip).toBe('123.4567000'); + }); + + it('handles minimum positive amount round-trip', () => { + const minAmount = '0.0000001'; + const stroops = amountToStroops(minAmount); + expect(stroops).toBe(1n); + expect(stroopsToAmount(stroops)).toBe('0.0000001'); + }); + + it('handles maximum amount round-trip', () => { + const stroops = amountToStroops(MAX_MONETARY_AMOUNT); + expect(stroopsToAmount(stroops)).toBe(`${MAX_MONETARY_AMOUNT}.0000000`); + }); + + it('amountToStroops throws on invalid money amount', () => { + expect(() => amountToStroops('0')).toThrow(); + expect(() => amountToStroops('1.12345678')).toThrow(); + expect(() => amountToStroops('invalid')).toThrow(); + }); + + it('stroopsToAmount throws on negative stroops', () => { + expect(() => stroopsToAmount(-1n)).toThrow( + 'Stroops amount must be non-negative', + ); + }); + }); + + describe('isSupportedEscrowAsset', () => { + it('accepts USDC and XLM', () => { + expect(isSupportedEscrowAsset(AssetType.USDC)).toBe(true); + expect(isSupportedEscrowAsset(AssetType.XLM)).toBe(true); + }); + + it('rejects unsupported assets', () => { + expect(isSupportedEscrowAsset('BTC')).toBe(false); + expect(isSupportedEscrowAsset('ETH')).toBe(false); + expect(isSupportedEscrowAsset(null)).toBe(false); + }); + }); +}); diff --git a/src/github/github-webhook-payload.util.spec.ts b/src/github/github-webhook-payload.util.spec.ts new file mode 100644 index 00000000..beafb3c8 --- /dev/null +++ b/src/github/github-webhook-payload.util.spec.ts @@ -0,0 +1,149 @@ +import { + validatePullRequestPayload, + validateIssuesEventPayload, + WebhookPayloadValidationError, +} from './github-webhook-payload.util'; + +describe('github-webhook-payload.util', () => { + describe('validatePullRequestPayload', () => { + const validPayload = { + action: 'opened', + number: 42, + pull_request: { + html_url: 'https://github.com/org/repo/pull/42', + number: 42, + merged: false, + title: 'Fix issue', + body: 'Closes #10', + }, + repository: { + id: 123456, + full_name: 'org/repo', + }, + }; + + it('accepts a minimal valid pull request payload', () => { + const result = validatePullRequestPayload(validPayload); + expect(result.action).toBe('opened'); + expect(result.number).toBe(42); + expect(result.pull_request.html_url).toBe( + 'https://github.com/org/repo/pull/42', + ); + expect(result.pull_request.merged).toBe(false); + expect(result.repository.id).toBe(123456); + expect(result.repository.full_name).toBe('org/repo'); + }); + + it('throws when payload is not an object', () => { + expect(() => validatePullRequestPayload(null)).toThrow( + WebhookPayloadValidationError, + ); + expect(() => validatePullRequestPayload('string')).toThrow( + WebhookPayloadValidationError, + ); + }); + + it('throws when action is missing or wrong type', () => { + const { action, ...missingAction } = validPayload; + expect(() => validatePullRequestPayload(missingAction)).toThrow( + WebhookPayloadValidationError, + ); + expect(() => + validatePullRequestPayload({ ...validPayload, action: 123 }), + ).toThrow(WebhookPayloadValidationError); + }); + + it('throws when number is missing or wrong type', () => { + const { number, ...missingNumber } = validPayload; + expect(() => validatePullRequestPayload(missingNumber)).toThrow( + WebhookPayloadValidationError, + ); + expect(() => + validatePullRequestPayload({ ...validPayload, number: '42' }), + ).toThrow(WebhookPayloadValidationError); + }); + + it('throws when pull_request or its required fields are missing', () => { + expect(() => + validatePullRequestPayload({ ...validPayload, pull_request: null }), + ).toThrow(WebhookPayloadValidationError); + + const invalidPr = { ...validPayload.pull_request, merged: 'not-bool' }; + expect(() => + validatePullRequestPayload({ ...validPayload, pull_request: invalidPr }), + ).toThrow(WebhookPayloadValidationError); + }); + + it('throws when repository or repository.id is missing', () => { + expect(() => + validatePullRequestPayload({ ...validPayload, repository: null }), + ).toThrow(WebhookPayloadValidationError); + + expect(() => + validatePullRequestPayload({ + ...validPayload, + repository: { id: 'invalid', full_name: 'org/repo' }, + }), + ).toThrow(WebhookPayloadValidationError); + }); + }); + + describe('validateIssuesEventPayload', () => { + const validIssuePayload = { + action: 'opened', + issue: { + id: 98765, + number: 10, + title: 'Bug report', + state: 'open', + updated_at: '2026-10-19T00:00:00Z', + html_url: 'https://github.com/org/repo/issues/10', + }, + repository: { + id: 123456, + full_name: 'org/repo', + }, + }; + + it('accepts a minimal valid issues event payload', () => { + const result = validateIssuesEventPayload(validIssuePayload); + expect(result.action).toBe('opened'); + expect(result.issue.id).toBe(98765); + expect(result.issue.title).toBe('Bug report'); + expect(result.repository.full_name).toBe('org/repo'); + }); + + it('accepts string issue.id', () => { + const result = validateIssuesEventPayload({ + ...validIssuePayload, + issue: { ...validIssuePayload.issue, id: '98765' }, + }); + expect(result.issue.id).toBe('98765'); + }); + + it('throws when issue.id is not a string or number', () => { + expect(() => + validateIssuesEventPayload({ + ...validIssuePayload, + issue: { ...validIssuePayload.issue, id: true }, + }), + ).toThrow(WebhookPayloadValidationError); + }); + + it('throws when required fields on issue are missing or wrong type', () => { + expect(() => + validateIssuesEventPayload({ + ...validIssuePayload, + issue: { ...validIssuePayload.issue, title: 123 }, + }), + ).toThrow(WebhookPayloadValidationError); + + expect(() => + validateIssuesEventPayload({ + ...validIssuePayload, + issue: { ...validIssuePayload.issue, state: null }, + }), + ).toThrow(WebhookPayloadValidationError); + }); + }); +}); diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index 0b370c4c..cadd59a9 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -81,6 +81,7 @@ export class MaintenancePoolController { } @ApiOperation({ summary: 'List maintenance pools' }) + @Throttle({ long: { limit: 1000, ttl: 3600000 } }) @Get() list() { return this.poolService.list(); @@ -88,6 +89,7 @@ export class MaintenancePoolController { @ApiOperation({ summary: 'Get a maintenance pool by id' }) @ApiStandardErrorResponses() + @Throttle({ default: { limit: 120, ttl: 60000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.poolService.findOne(id); diff --git a/src/maintenance-pool/maintenance-pool.service.ts b/src/maintenance-pool/maintenance-pool.service.ts index 13415a62..2fc57edf 100644 --- a/src/maintenance-pool/maintenance-pool.service.ts +++ b/src/maintenance-pool/maintenance-pool.service.ts @@ -46,7 +46,10 @@ export class MaintenancePoolService { } async findOne(id: string): Promise { - const pool = await this.poolRepo.findOne({ where: { id } }); + const pool = await this.poolRepo.findOne({ + where: { id }, + relations: { repository: true }, + }); if (!pool) throw new NotFoundException(`Maintenance pool ${id} not found`); return pool; } @@ -196,6 +199,8 @@ export class MaintenancePoolService { } async list(): Promise { - return this.poolRepo.find(); + return this.poolRepo.find({ + relations: { repository: true }, + }); } } diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d99..2e63692a 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({ default: { limit: 120, ttl: 60000 } }) @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.milestonesService.findOne(id); diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 1b2389b9..c4def4e6 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -239,7 +239,9 @@ export class MilestonesService { } async list(): Promise { - return this.milestoneRepo.find(); + return this.milestoneRepo.find({ + relations: { repository: true, issues: true }, + }); } /**