diff --git a/src/common/validators/date.validator.spec.ts b/src/common/validators/date.validator.spec.ts new file mode 100644 index 00000000..bee229e0 --- /dev/null +++ b/src/common/validators/date.validator.spec.ts @@ -0,0 +1,38 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { IsFutureDate } from './date.validator'; + +class TestDto { + @IsFutureDate() + deadline!: string; +} + +describe('date.validator (@IsFutureDate)', () => { + it('accepts a date strictly in the future', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + const dto = plainToInstance(TestDto, { deadline: future }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a date in the past', async () => { + const past = new Date(Date.now() - 3_600_000).toISOString(); + const dto = plainToInstance(TestDto, { deadline: past }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); + + it('rejects the current instant', async () => { + const now = new Date().toISOString(); + const dto = plainToInstance(TestDto, { deadline: now }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); + + it('rejects a malformed date-string', async () => { + const dto = plainToInstance(TestDto, { deadline: 'not-a-date' }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); + + it('rejects a non-string', async () => { + const dto = plainToInstance(TestDto, { deadline: 123 as any }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); +}); diff --git a/src/common/validators/money.validator.spec.ts b/src/common/validators/money.validator.spec.ts new file mode 100644 index 00000000..9b1f72b4 --- /dev/null +++ b/src/common/validators/money.validator.spec.ts @@ -0,0 +1,77 @@ +import { + isValidMoneyAmount, + amountToStroops, + stroopsToAmount, + MAX_MONETARY_AMOUNT, +} from './money.validator'; + +describe('money.validator', () => { + describe('isValidMoneyAmount', () => { + it('accepts a plain positive integer-string', () => { + expect(isValidMoneyAmount('100')).toBe(true); + expect(isValidMoneyAmount('1')).toBe(true); + }); + + it('accepts up to 7 fractional digits', () => { + expect(isValidMoneyAmount('1.5')).toBe(true); + expect(isValidMoneyAmount('0.1234567')).toBe(true); + }); + + it('rejects more than 7 fractional digits (stroop precision)', () => { + expect(isValidMoneyAmount('0.12345678')).toBe(false); + }); + + it('rejects zero and negative', () => { + expect(isValidMoneyAmount('0')).toBe(false); + expect(isValidMoneyAmount('-5')).toBe(false); + expect(isValidMoneyAmount('0.0')).toBe(false); + }); + + it('rejects non-string and malformed input', () => { + expect(isValidMoneyAmount(100)).toBe(false); + expect(isValidMoneyAmount('abc')).toBe(false); + expect(isValidMoneyAmount('')).toBe(false); + expect(isValidMoneyAmount('1.')).toBe(false); + expect(isValidMoneyAmount('.5')).toBe(false); + expect(isValidMoneyAmount('01')).toBe(false); // leading zero + }); + + it('rejects an amount above the max', () => { + expect(isValidMoneyAmount(`${Number(MAX_MONETARY_AMOUNT) + 1}`)).toBe( + false, + ); + }); + }); + + describe('amountToStroops', () => { + it('converts a whole amount to stroops', () => { + expect(amountToStroops('1')).toBe(10_000_000n); + expect(amountToStroops('100')).toBe(1_000_000_000n); + }); + + it('converts a fractional amount', () => { + expect(amountToStroops('1.5')).toBe(15_000_000n); + expect(amountToStroops('0.0000001')).toBe(1n); + }); + + it('throws on an invalid amount', () => { + expect(() => amountToStroops('not a number')).toThrow(); + expect(() => amountToStroops('-1')).toThrow(); + }); + }); + + describe('stroopsToAmount', () => { + it('formats a stroop total back to a decimal string', () => { + expect(stroopsToAmount(10_000_000n)).toBe('1.0000000'); + expect(stroopsToAmount(15_000_000n)).toBe('1.5000000'); + }); + + it('round-trips through amountToStroops', () => { + expect(stroopsToAmount(amountToStroops('42.75'))).toBe('42.7500000'); + }); + + it('throws on a negative stroop total', () => { + expect(() => stroopsToAmount(-1n)).toThrow(); + }); + }); +}); diff --git a/src/common/validators/stellar-address.validator.spec.ts b/src/common/validators/stellar-address.validator.spec.ts new file mode 100644 index 00000000..1c3d35f1 --- /dev/null +++ b/src/common/validators/stellar-address.validator.spec.ts @@ -0,0 +1,58 @@ +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { + IsStellarAddress, + isValidStellarAddress, +} from './stellar-address.validator'; + +// A real StrKey-encoded Ed25519 public key (the well-known all-zero account). +const VALID_ADDRESS = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const INVALID_ADDRESS = 'GNOTAREALADDRESS'; +const VALID_BUT_DIFFERENT = + 'GAVDFIZJHGGR3JR3TKFYO6GMQIPTQQSG6RL4R33XATU37LNWSLXU6OWH'; + +class TestDto { + @IsStellarAddress() + address!: string; +} + +describe('stellar-address.validator', () => { + describe('isValidStellarAddress', () => { + it('accepts a valid StrKey Ed25519 public key', () => { + expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true); + expect(isValidStellarAddress(VALID_BUT_DIFFERENT)).toBe(true); + }); + + it('rejects a syntactically-wrong address', () => { + expect(isValidStellarAddress(INVALID_ADDRESS)).toBe(false); + expect(isValidStellarAddress('hello')).toBe(false); + }); + + it('rejects a plausible-but-checksum-invalid address', () => { + // Same length + G-prefix, last char swapped — fails the CRC16 checksum. + const tampered = VALID_ADDRESS.slice(0, -1) + 'X'; + expect(isValidStellarAddress(tampered)).toBe(false); + }); + + it('rejects non-strings', () => { + expect(isValidStellarAddress(123)).toBe(false); + expect(isValidStellarAddress(null)).toBe(false); + expect(isValidStellarAddress(undefined)).toBe(false); + expect(isValidStellarAddress({})).toBe(false); + }); + }); + + describe('@IsStellarAddress decorator', () => { + it('passes validation on a valid address', async () => { + const dto = plainToInstance(TestDto, { address: VALID_ADDRESS }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('fails validation on an invalid address', async () => { + const dto = plainToInstance(TestDto, { address: INVALID_ADDRESS }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + }); +}); 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/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,