Skip to content
Open
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
7 changes: 5 additions & 2 deletions src/escrow/dto/fund-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
IsDate,
IsOptional,
IsString,
IsUUID,
Expand Down Expand Up @@ -89,7 +89,10 @@ export class FundEscrowDto {
'ISO 8601 refund-deadline passed to escrow::fund (defaults to the configured window)',
})
@IsOptional()
@IsDateString()
// @Type(() => Date) transforms before validation, so IsDateString (which
// requires a string) would reject every real deadline. IsDate accepts the
// transformed value and still rejects NaN "Invalid Date" results.
@IsDate()
@Type(() => Date)
deadline?: Date;
}
22 changes: 11 additions & 11 deletions src/escrow/escrow.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('EscrowController', () => {
});

it('release() returns the service escrow without metadata', async () => {
const result = await controller.release('escrow-1', 'GRECIPIENT', 'user-1');
const result = await controller.release('escrow-1', { recipientAddress: 'GRECIPIENT', recipientId: 'user-1' });

expect(mockEscrowService.release).toHaveBeenCalledWith(
'escrow-1',
Expand All @@ -142,7 +142,7 @@ describe('EscrowController', () => {
{ recipientAddress: 'GB', percentage: 50 },
];

const result = await controller.splitRelease('escrow-1', recipients);
const result = await controller.splitRelease('escrow-1', { recipients });

expect(mockEscrowService.splitRelease).toHaveBeenCalledWith(
'escrow-1',
Expand Down Expand Up @@ -210,11 +210,11 @@ describe('EscrowController', () => {

it('release() is restricted to MAINTAINER and refund() to MAINTAINER/SPONSOR', () => {
expect(Reflect.getMetadata(ROLES_KEY, handlerFor('release'))).toEqual([
'MAINTAINER',
'maintainer',
]);
expect(Reflect.getMetadata(ROLES_KEY, handlerFor('refund'))).toEqual([
'MAINTAINER',
'SPONSOR',
'maintainer',
'sponsor',
]);
});

Expand All @@ -235,15 +235,15 @@ describe('EscrowController', () => {
const base = {
amount: '100.0000000',
asset: AssetType.USDC,
funderAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567abcdefghijklmn',
bountyId: '00000000-0000-0000-0000-000000000001',
funderAddress: 'GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A',
bountyId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
};

it('accepts onChainIssueId, sponsorId and deadline', async () => {
const dto = plainToInstance(FundEscrowDto, {
...base,
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: '2026-12-31T00:00:00.000Z',
});
const errors = await validate(dto);
Expand Down Expand Up @@ -289,7 +289,7 @@ describe('EscrowController', () => {
const dto = plainToInstance(FundEscrowDto, {
amount: 'not-money',
asset: AssetType.USDC,
funderAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567abcdefghijklmn',
funderAddress: 'GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
Expand All @@ -299,15 +299,15 @@ describe('EscrowController', () => {
const dto = plainToInstance(FundEscrowDto, {
...base,
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: '2026-12-31T00:00:00.000Z',
});
mockEscrowService.fund.mockResolvedValueOnce({ id: 'escrow-1' });
await controller.fund(dto);
expect(mockEscrowService.fund).toHaveBeenCalledWith(
expect.objectContaining({
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: new Date('2026-12-31T00:00:00.000Z'),
}),
);
Expand Down
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
30 changes: 16 additions & 14 deletions src/github/github-webhooks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import * as sigUtil from './webhook-signature.util';

describe('GithubWebhooksService', () => {
let service: GithubWebhooksService;
let webhookEventRepo: { create: jest.Mock; save: jest.Mock };
let webhookEventRepo: {
create: jest.Mock;
save: jest.Mock;
Expand Down Expand Up @@ -40,6 +39,7 @@ describe('GithubWebhooksService', () => {
findOne: jest.fn().mockResolvedValue(null),
};
bountyRepo = { findOne: jest.fn() };
issueRepo = { findOne: jest.fn().mockResolvedValue(null) };
bountiesService = {
markInReview: jest.fn().mockResolvedValue(undefined),
markMergedAndRelease: jest.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -264,11 +264,11 @@ describe('GithubWebhooksService', () => {
byNumber: Record<number, { bountyId: string; status: string }>,
) {
syncService.findIssueByRepoAndNumber.mockImplementation(
({ where }: { where: { number: number } }) => {
const entry = byNumber[where.number];
(_repoId: string, number: number) => {
const entry = byNumber[number];
return Promise.resolve(
entry
? { id: `issue-${where.number}`, bounty: { id: entry.bountyId } }
? { id: `issue-${number}`, bounty: { id: entry.bountyId } }
: null,
);
},
Expand Down Expand Up @@ -637,9 +637,8 @@ describe('GithubWebhooksService', () => {
describe('comma-separated closing references (#309)', () => {
/** Collects every issue number the merged-PR path ends up releasing. */
function releasedNumbers(): number[] {
return issueRepo.findOne.mock.calls.map(
(call: unknown[]) =>
(call[0] as { where: { number: number } }).where.number,
return syncService.findIssueByRepoAndNumber.mock.calls.map(
(call: unknown[]) => call[1] as number,
);
}

Expand Down Expand Up @@ -732,11 +731,11 @@ describe('GithubWebhooksService', () => {
});

it('marks a comma-separated bounty in review when the PR is opened', async () => {
issueRepo.findOne.mockImplementation(
({ where }: { where: { number: number } }) =>
syncService.findIssueByRepoAndNumber.mockImplementation(
(_repoId: string, number: number) =>
Promise.resolve({
id: `issue-${where.number}`,
bounty: { id: `bounty-${where.number}` },
id: `issue-${number}`,
bounty: { id: `bounty-${number}` },
}),
);
bountyRepo.findOne.mockImplementation(
Expand Down Expand Up @@ -782,10 +781,10 @@ describe('GithubWebhooksService', () => {

describe('pull_request edited events (#310)', () => {
it('moves a bounty to in_review when a closing keyword is added after opening', async () => {
issueRepo.findOne.mockImplementation(
({ where }: { where: { number: number } }) =>
syncService.findIssueByRepoAndNumber.mockImplementation(
(_repoId: string, number: number) =>
Promise.resolve({
id: `issue-${where.number}`,
id: `issue-${number}`,
bounty: { id: 'bounty-42' },
}),
);
Expand Down Expand Up @@ -869,6 +868,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
46 changes: 43 additions & 3 deletions src/milestones/milestones.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('MilestonesService', () => {
let service: MilestonesService;
let milestoneRepo: {
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
update: jest.Mock;
};
Expand All @@ -24,11 +25,13 @@ describe('MilestonesService', () => {
};
let dataSource: {
transaction: jest.Mock;
query: jest.Mock;
};

beforeEach(async () => {
milestoneRepo = {
findOne: jest.fn(),
create: jest.fn((m: Partial<Milestone>) => m),
save: jest.fn((m: Partial<Milestone>) => Promise.resolve(m)),
update: jest.fn(),
};
Expand All @@ -50,6 +53,7 @@ describe('MilestonesService', () => {
update: jest.fn(),
}),
),
query: jest.fn().mockResolvedValue([{ exists: 1 }]),
};

const module: TestingModule = await Test.createTestingModule({
Expand All @@ -75,7 +79,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');
Expand All @@ -90,7 +94,7 @@ describe('MilestonesService', () => {
sponsorId: 'sponsor-1',
});

await service.fund('m1', 'GFUNDER');
await service.fund('m1', 'GFUNDER', 'sponsor-1');

expect(escrowService.fund).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -263,6 +267,7 @@ describe('MilestonesService', () => {
'250.0000000',
'RECIPIENT_ADDR',
'user-1',
expect.anything(),
);
expect(payment.id).toBe('payment-1');
expect(dataSource.transaction).toHaveBeenCalled();
Expand Down Expand Up @@ -341,7 +346,7 @@ describe('MilestonesService', () => {

await expect(
service.resolveIssue('m1', 'i1', 'RECIPIENT_ADDR'),
).rejects.toThrow('Milestone m1 is not accepting distributions');
).rejects.toThrow('Cannot transition milestone');
});

it('rejects when the issue does not belong to the milestone (#114)', async () => {
Expand Down Expand Up @@ -406,7 +411,42 @@ describe('MilestonesService', () => {
'333.3333333',
'RECIPIENT_ADDR',
undefined,
expect.anything(),
);
});
});

describe('create — #40 sponsor binding', () => {
const dto = {
repositoryId: 'repo-a',
sponsorId: 'spoofed-attacker-id',
title: 'Fix auth',
description: 'desc',
budget: '500',
asset: AssetType.USDC,
deadline: null,
} as any;

it('ignores a client-supplied sponsorId and binds the authenticated caller', async () => {
const saved = await service.create(dto, 'caller-1');
expect(saved.sponsorId).toBe('caller-1');
expect(milestoneRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ sponsorId: 'caller-1' }),
);
});

it('binds the caller when sponsorId is omitted', async () => {
const { sponsorId: _drop, ...noSponsor } = dto;
const saved = await service.create(noSponsor, 'caller-9');
expect(saved.sponsorId).toBe('caller-9');
});

it('throws NotFoundException when the repository row does not exist', async () => {
dataSource.query.mockResolvedValueOnce([]);
await expect(service.create(dto, 'caller-1')).rejects.toThrow(
'Repository repo-a not found',
);
expect(milestoneRepo.save).not.toHaveBeenCalled();
});
});
});
9 changes: 5 additions & 4 deletions src/milestones/milestones.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ export class MilestonesService {
throw new NotFoundException(`Repository ${dto.repositoryId} not found`);
}

// Fall back to the authenticated caller's id when the client omits sponsorId.
// This prevents milestones with sponsorId = null that become permanently
// invisible to every sponsor dashboard filter.
// The sponsor is always the authenticated caller — never a client-
// supplied sponsorId. Otherwise a caller could create a milestone owned
// by someone else, and the sponsor-only ops (fund/close) would gate on
// the wrong identity (#40).
const milestone = this.milestoneRepo.create({
repositoryId: dto.repositoryId,
sponsorId: dto.sponsorId ?? callerUserId,
sponsorId: callerUserId,
title: dto.title?.trim() ?? dto.title,
description: dto.description ?? null,
budget: dto.budget,
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));
}
}
9 changes: 9 additions & 0 deletions test/mocks/stellar-sdk.mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ class TransactionBuilder {
}
}


class StrKey {
// Tests only need accept/reject — real CRC16 stays in the SDK's prod path.
static isValidEd25519PublicKey(value) {
return typeof value === 'string' && /^G[A-Z2-7]{55}$/.test(value);
}
}

module.exports = {
StrKey,
Contract,
Address,
Keypair,
Expand Down