Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/common/validators/date.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
77 changes: 77 additions & 0 deletions src/common/validators/money.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
58 changes: 58 additions & 0 deletions src/common/validators/stellar-address.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
10 changes: 2 additions & 8 deletions src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,20 +584,14 @@ export class EscrowService {
recipients: Array<[string, number]>,
manager?: EntityManager,
): Promise<ContractInvocationResult> {
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,
Expand Down
3 changes: 3 additions & 0 deletions src/github/github-webhooks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 0 additions & 21 deletions src/sponsors/sponsors.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand All @@ -91,6 +71,5 @@ export class SponsorsController {
id,
validatePage(limit, offset),
);
return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset));
}
}
49 changes: 49 additions & 0 deletions test/mocks/stellar-sdk.mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down