Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { BountiesService } from './bounties.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ClaimBountyDto } from './dto/claim-bounty.dto';
import { BountyStatus } from '../common/enums';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { IsStellarAddress } from '../common/validators/stellar-address.validator';

class FundBountyDto {
@IsString()
@IsStellarAddress()
funderAddress: string;
}

Expand Down
35 changes: 35 additions & 0 deletions src/common/validators/stellar-address.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
import { StrKey } from '@stellar/stellar-sdk';

/**
* Backed by the SDK's own StrKey checksum validation (base32 decode +
* version byte + CRC16 checksum over the payload) rather than a hand-rolled
* regex, so a syntactically-plausible-but-checksum-invalid address is
* rejected the same as an obviously malformed one (#60).
*/
export function isValidStellarAddress(value: unknown): value is string {
return typeof value === 'string' && StrKey.isValidEd25519PublicKey(value);
}

export function IsStellarAddress(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isStellarAddress',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
return isValidStellarAddress(value);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid Stellar public key (StrKey-encoded Ed25519 address)`;
},
},
});
};
}
5 changes: 3 additions & 2 deletions src/escrow/dto/fund-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { AssetType } from '../../common/enums';
import {
IsMoneyAmount,
IsSupportedEscrowAsset,
} from '../../common/validators/money.validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class FundEscrowDto {
@ApiProperty({ description: 'Amount to lock in the escrow contract' })
Expand All @@ -16,7 +17,7 @@ export class FundEscrowDto {
asset: AssetType;

@ApiProperty({ description: 'Stellar public key of the funding sponsor' })
@IsString()
@IsStellarAddress()
funderAddress: string;

@ApiProperty({ required: false })
Expand Down
5 changes: 3 additions & 2 deletions src/escrow/dto/release-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class ReleaseEscrowDto {
@ApiProperty({ description: 'Stellar public key of the recipient' })
@IsString()
@IsStellarAddress()
recipientAddress: string;

@ApiProperty({ required: false })
Expand Down
4 changes: 2 additions & 2 deletions src/escrow/dto/split-release.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import {
ArrayMinSize,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class SplitRecipientDto {
@ApiProperty()
@IsString()
@IsStellarAddress()
recipientAddress: string;

@ApiProperty({ required: false })
Expand Down
7 changes: 4 additions & 3 deletions src/maintenance-pool/maintenance-pool.controller.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { MaintenancePoolService } from './maintenance-pool.service';
import { CreatePoolDto } from './dto/create-pool.dto';
import { IsMoneyAmount } from '../common/validators/money.validator';
import { IsStellarAddress } from '../common/validators/stellar-address.validator';
import { Idempotent } from '../common/idempotency/idempotent.decorator';

class DepositDto {
@IsMoneyAmount()
amount: string;

@IsString()
@IsStellarAddress()
funderAddress: string;
}

class AssignRewardDto {
@IsMoneyAmount()
amount: string;

@IsString()
@IsStellarAddress()
recipientAddress: string;

@IsOptional()
Expand Down
7 changes: 4 additions & 3 deletions src/milestones/milestones.controller.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { MilestonesService } from './milestones.service';
import { CreateMilestoneDto } from './dto/create-milestone.dto';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { IsStellarAddress } from '../common/validators/stellar-address.validator';

class FundMilestoneDto {
@IsString()
@IsStellarAddress()
funderAddress: string;
}

class ResolveIssueDto {
@IsString()
@IsStellarAddress()
recipientAddress: string;

@IsOptional()
Expand Down
209 changes: 209 additions & 0 deletions test/stellar-address-validation-bounties-milestones.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import request from 'supertest';
import { randomUUID } from 'crypto';
import { Keypair, StrKey } from '@stellar/stellar-sdk';
import { BountiesController } from '../src/bounties/bounties.controller';
import { BountiesService } from '../src/bounties/bounties.service';
import { MilestonesController } from '../src/milestones/milestones.controller';
import { MilestonesService } from '../src/milestones/milestones.service';
import { IdempotencyKeyStatus } from '../src/common/enums';
import { IdempotencyKey } from '../src/common/entities/idempotency-key.entity';
import { IdempotencyInterceptor } from '../src/common/idempotency/idempotency.interceptor';

/** Same in-memory stand-in used across this directory's e2e specs — see
* escrow-idempotency.e2e-spec.ts's FakeIdempotencyRepo for the rationale
* behind duplicating it per file instead of sharing one implementation. */
class FakeIdempotencyRepo {
rows: Partial<IdempotencyKey>[] = [];

findOneBy(
where: Partial<IdempotencyKey>,
): Promise<Partial<IdempotencyKey> | null> {
return Promise.resolve(
this.rows.find((r) =>
(Object.keys(where) as (keyof IdempotencyKey)[]).every(
(k) => r[k] === where[k],
),
) ?? null,
);
}

insert(data: Partial<IdempotencyKey>): Promise<void> {
this.rows.push({
...data,
status: IdempotencyKeyStatus.PROCESSING,
responseStatus: null,
responseBody: null,
updatedAt: new Date(),
});
return Promise.resolve();
}

update(
criteria: Partial<IdempotencyKey>,
partial: Partial<IdempotencyKey>,
): Promise<{ affected: number }> {
let affected = 0;
for (const row of this.rows) {
if (
(Object.keys(criteria) as (keyof IdempotencyKey)[]).every(
(k) => row[k] === criteria[k],
)
) {
Object.assign(row, partial);
affected += 1;
}
}
return Promise.resolve({ affected });
}

delete(criteria: Partial<IdempotencyKey>): Promise<{ affected: number }> {
const before = this.rows.length;
this.rows = this.rows.filter(
(r) =>
!(Object.keys(criteria) as (keyof IdempotencyKey)[]).every(
(k) => r[k] === criteria[k],
),
);
return Promise.resolve({ affected: before - this.rows.length });
}
}

function checksumInvalidAddress(): string {
const valid = Keypair.random().publicKey();
const flippedChar = valid[10] === 'A' ? 'B' : 'A';
const candidate = valid.slice(0, 10) + flippedChar + valid.slice(11);
if (StrKey.isValidEd25519PublicKey(candidate)) {
return checksumInvalidAddress();
}
return candidate;
}

function newFakeRepoProvider() {
return {
provide: getRepositoryToken(IdempotencyKey),
useValue: new FakeIdempotencyRepo(),
};
}

describe('Stellar address validation at the API boundary — bounties & milestones endpoints (#60)', () => {
let app: INestApplication;
let bountiesService: { fund: jest.Mock };
let milestonesService: { fund: jest.Mock; resolveIssue: jest.Mock };

beforeAll(async () => {
bountiesService = {
fund: jest.fn().mockResolvedValue({ id: 'bounty_1' }),
};
milestonesService = {
fund: jest.fn().mockResolvedValue({ id: 'milestone_1' }),
resolveIssue: jest.fn().mockResolvedValue({ id: 'milestone_1' }),
};

const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [BountiesController, MilestonesController],
providers: [
{ provide: BountiesService, useValue: bountiesService },
{ provide: MilestonesService, useValue: milestonesService },
IdempotencyInterceptor,
Reflector,
newFakeRepoProvider(),
],
}).compile();

app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
await app.init();
});

afterAll(async () => {
await app.close();
});

beforeEach(() => {
bountiesService.fund.mockClear();
milestonesService.fund.mockClear();
milestonesService.resolveIssue.mockClear();
});

const badAddresses = () => [
'',
'not-an-address',
'G'.repeat(55),
checksumInvalidAddress(),
];

it.each(badAddresses())(
'rejects POST /bounties/:id/fund with a malformed funderAddress (%p) as 400',
async (bad) => {
await request(app.getHttpServer())

Check warning on line 144 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/bounties/bounty_1/fund')
.set('Idempotency-Key', randomUUID())
.send({ funderAddress: bad })
.expect(400);

expect(bountiesService.fund).not.toHaveBeenCalled();
},
);

it('accepts POST /bounties/:id/fund with a valid funderAddress', async () => {
await request(app.getHttpServer())

Check warning on line 155 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/bounties/bounty_1/fund')
.set('Idempotency-Key', randomUUID())
.send({ funderAddress: Keypair.random().publicKey() })
.expect(201);

expect(bountiesService.fund).toHaveBeenCalledTimes(1);
});

it.each(badAddresses())(
'rejects POST /milestones/:id/fund with a malformed funderAddress (%p) as 400',
async (bad) => {
await request(app.getHttpServer())

Check warning on line 167 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/milestones/milestone_1/fund')
.set('Idempotency-Key', randomUUID())
.send({ funderAddress: bad })
.expect(400);

expect(milestonesService.fund).not.toHaveBeenCalled();
},
);

it('accepts POST /milestones/:id/fund with a valid funderAddress', async () => {
await request(app.getHttpServer())

Check warning on line 178 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/milestones/milestone_1/fund')
.set('Idempotency-Key', randomUUID())
.send({ funderAddress: Keypair.random().publicKey() })
.expect(201);

expect(milestonesService.fund).toHaveBeenCalledTimes(1);
});

it.each(badAddresses())(
'rejects POST /milestones/:id/issues/:issueId/resolve with a malformed recipientAddress (%p) as 400',
async (bad) => {
await request(app.getHttpServer())

Check warning on line 190 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/milestones/milestone_1/issues/issue_1/resolve')
.set('Idempotency-Key', randomUUID())
.send({ recipientAddress: bad })
.expect(400);

expect(milestonesService.resolveIssue).not.toHaveBeenCalled();
},
);

it('accepts POST /milestones/:id/issues/:issueId/resolve with a valid recipientAddress', async () => {
await request(app.getHttpServer())

Check warning on line 201 in test/stellar-address-validation-bounties-milestones.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.post('/milestones/milestone_1/issues/issue_1/resolve')
.set('Idempotency-Key', randomUUID())
.send({ recipientAddress: Keypair.random().publicKey() })
.expect(201);

expect(milestonesService.resolveIssue).toHaveBeenCalledTimes(1);
});
});
Loading
Loading