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
6 changes: 3 additions & 3 deletions harvest-finance/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { StellarStrategy } from './strategies/stellar.strategy';

/**
* Authentication Controller
*
*
* Throttling Tiers Overview:
* - short: Strict rate limits for high-risk or resource-intensive operations (e.g., login, password reset). Protects against brute-force attacks.
* - medium: Moderate limits for standard operations (e.g., token refresh, generating challenges). Balances usability with spam prevention.
Expand All @@ -62,7 +62,7 @@ export class AuthController {
/**
* Register a new user
*
* Uses long tier: Registration is an infrequent operation, so a longer
* Uses long tier: Registration is an infrequent operation, so a longer
* window prevents spam while allowing normal user onboarding.
*/
@Post('register')
Expand Down Expand Up @@ -98,7 +98,7 @@ export class AuthController {
/**
* Login user
*
* Uses stricter long tier limits: Login is a high-value target for
* Uses stricter long tier limits: Login is a high-value target for
* brute-force attacks and requires tighter throttling.
*/
@Post('login')
Expand Down
6 changes: 4 additions & 2 deletions harvest-finance/backend/src/auth/dto/auth-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,16 @@ export class TokenResponseDto {
/** Freshly issued short-lived JWT access token. */
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'JWT access token to be sent as a Bearer token in the Authorization header',
description:
'JWT access token to be sent as a Bearer token in the Authorization header',
})
access_token: string;

/** OAuth 2.0 token type. Always "Bearer" for this API. */
@ApiProperty({
example: 'Bearer',
description: 'Token type — always "Bearer". Prefix the access_token with this value in the Authorization header.',
description:
'Token type — always "Bearer". Prefix the access_token with this value in the Authorization header.',
})
token_type: string;
}
Expand Down
9 changes: 4 additions & 5 deletions harvest-finance/backend/src/auth/stellar.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ describe('Stellar Authentication Integration', () => {
let testClientPublicKey: string;

beforeAll(async () => {
testServerPublicKey = StellarSdk.Keypair.fromSecret(
testServerSecret,
).publicKey();
testServerPublicKey =
StellarSdk.Keypair.fromSecret(testServerSecret).publicKey();
testClientKeypair = StellarSdk.Keypair.random();
testClientSecret = testClientKeypair.secret();
testClientPublicKey = testClientKeypair.publicKey();
Expand Down Expand Up @@ -121,7 +120,7 @@ describe('Stellar Authentication Integration', () => {

// Verify transaction structure
expect(transaction.source).toBe(testServerPublicKey);
expect(transaction.sequence).toBe('1');
expect(transaction.sequence).toBe('0');
expect(transaction.operations.length).toBe(1);
expect(transaction.operations[0].type).toBe('manageData');
expect(transaction.operations[0].name).toBe('Harvest Finance auth');
Expand Down Expand Up @@ -461,7 +460,7 @@ describe('Stellar Authentication Integration', () => {
testNetworkPassphrase,
) as StellarSdk.Transaction;

expect(transaction.sequence).toBe('1');
expect(transaction.sequence).toBe('0');
});

it('should include proper operation name', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ export class StellarStrategy extends PassportStrategy(
const now = Math.floor(Date.now() / 1000);
const minTime = parseInt(transaction.timeBounds?.minTime || '0');
const maxTime = parseInt(transaction.timeBounds?.maxTime || '0');

if (now < minTime || now > maxTime) {
throw new UnauthorizedException('Challenge transaction expired');
}
Expand All @@ -206,6 +205,11 @@ export class StellarStrategy extends PassportStrategy(
if (operation.name !== 'Harvest Finance auth') {
throw new UnauthorizedException('Invalid operation name');
}

// Check sequence number is 1 for a challenge transaction created from an account with sequence 0
if (transaction.sequence !== '0') {
throw new UnauthorizedException('Invalid sequence number');
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ describe('ContractCacheService', () => {
describe('Cache Hit', () => {
it('should return cached value and not call fetcher', async () => {
const fetcher = jest.fn().mockResolvedValue('fresh-data');

// Initial call - cache miss
await service.getVaultState('vault-1', fetcher);

// Second call - cache hit
const result = await service.getVaultState('vault-1', fetcher);

expect(result).toBe('fresh-data');
expect(fetcher).toHaveBeenCalledTimes(1); // Only called once
});
Expand All @@ -71,42 +71,54 @@ describe('ContractCacheService', () => {
it('should fetch fresh data and store in cache for vault state', async () => {
const fetcher = jest.fn().mockResolvedValue('vault-data');
const result = await service.getVaultState('vault-2', fetcher);

expect(result).toBe('vault-data');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(cacheManager.set).toHaveBeenCalledWith('vault:state:vault-2', 'vault-data', 60);
expect(cacheManager.set).toHaveBeenCalledWith(
'vault:state:vault-2',
'vault-data',
60,
);
});

it('should fetch fresh data and store in cache for account info', async () => {
const fetcher = jest.fn().mockResolvedValue('account-data');
const result = await service.getAccountInfo('pub-key', fetcher);

expect(result).toBe('account-data');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(cacheManager.set).toHaveBeenCalledWith('account:info:pub-key', 'account-data', 600);
expect(cacheManager.set).toHaveBeenCalledWith(
'account:info:pub-key',
'account-data',
600,
);
});

it('should fetch fresh data and store in cache for contract data with default TTL', async () => {
const fetcher = jest.fn().mockResolvedValue('contract-data');
const result = await service.getContractData('key-1', fetcher);

expect(result).toBe('contract-data');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(cacheManager.set).toHaveBeenCalledWith('contract:key-1', 'contract-data', 300);
expect(cacheManager.set).toHaveBeenCalledWith(
'contract:key-1',
'contract-data',
300,
);
});
});

describe('TTL Expiry', () => {
it('should expire cache after TTL and fetch fresh data', async () => {
const fetcher1 = jest.fn().mockResolvedValue('data-1');
await service.getVaultState('vault-3', fetcher1);

// Fast-forward time past VAULT_STATE_TTL (60 seconds)
jest.advanceTimersByTime(61 * 1000);

const fetcher2 = jest.fn().mockResolvedValue('data-2');
const result = await service.getVaultState('vault-3', fetcher2);

expect(result).toBe('data-2');
expect(fetcher2).toHaveBeenCalledTimes(1);
});
Expand All @@ -116,12 +128,12 @@ describe('ContractCacheService', () => {
it('should remove entry and force fresh fetch on next call', async () => {
const fetcher1 = jest.fn().mockResolvedValue('data-1');
await service.getVaultState('vault-4', fetcher1);

await service.invalidate('vault:state:vault-4');

const fetcher2 = jest.fn().mockResolvedValue('fresh-data');
const result = await service.getVaultState('vault-4', fetcher2);

expect(result).toBe('fresh-data');
expect(fetcher2).toHaveBeenCalledTimes(1);
expect(cacheManager.del).toHaveBeenCalledWith('vault:state:vault-4');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,26 +123,28 @@ describe('InputSanitizerService', () => {
});

it('rejects a G-address (public key) passed as a contract ID', () => {
expect(() =>
service.validateContractId(validStellarPublicKey),
).toThrow(BadRequestException);
expect(() => service.validateContractId(validStellarPublicKey)).toThrow(
BadRequestException,
);
});

it('rejects a 64-character hex string (not a Stellar C-address)', () => {
expect(() =>
service.validateContractId('a'.repeat(64)),
).toThrow(BadRequestException);
expect(() => service.validateContractId('a'.repeat(64))).toThrow(
BadRequestException,
);
});

it('rejects an odd-length hex string', () => {
expect(() =>
service.validateContractId('abc'),
).toThrow(BadRequestException);
expect(() => service.validateContractId('abc')).toThrow(
BadRequestException,
);
});

it('rejects a string with non-hex, non-base32 characters', () => {
expect(() =>
service.validateContractId('ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'),
service.validateContractId(
'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ',
),
).toThrow(BadRequestException);
});

Expand All @@ -166,9 +168,7 @@ describe('InputSanitizerService', () => {
);

it('includes example C-address format in the empty-input error message', () => {
expect(() => service.validateContractId('')).toThrow(
/C-address format/,
);
expect(() => service.validateContractId('')).toThrow(/C-address format/);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vault } from './vault.entity';
import { User } from './user.entity';

export enum InsuranceClaimStatus {
PENDING = 'PENDING',
COMPLETED = 'COMPLETED',
REJECTED = 'REJECTED',
}

/**
* Records a payout claim from the insurance fund to a depositor.
* The claim is created during an incident workflow and later updated
* when the payout is processed on‑chain.
*/
@Entity('insurance_claims')
@Index('idx_insurance_claim_vault', ['vaultId'])
@Index('idx_insurance_claim_user', ['depositorId'])
export class InsuranceClaim {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'vault_id' })
vaultId: string;

@ManyToOne(() => Vault, (vault) => vault.id, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vault_id' })
vault: Vault;

@Column({ name: 'depositor_id' })
depositorId: string;

@ManyToOne(() => User, (user) => user.id, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'depositor_id' })
depositor: User;

@Column({ type: 'decimal', precision: 18, scale: 8 })
lossAmount: number;

@Column({ type: 'decimal', precision: 18, scale: 8 })
payoutAmount: number;

@Column({ type: 'enum', enum: InsuranceClaimStatus, default: InsuranceClaimStatus.PENDING })
status: InsuranceClaimStatus;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSorobanEventQueryIndexes1700000000013
implements MigrationInterface
{
export class AddSorobanEventQueryIndexes1700000000013 implements MigrationInterface {
name = 'AddSorobanEventQueryIndexes1700000000013';

public async up(queryRunner: QueryRunner): Promise<void> {
Expand All @@ -15,9 +13,7 @@ export class AddSorobanEventQueryIndexes1700000000013
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "idx_soroban_events_query"`,
);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_soroban_events_query"`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_soroban_events_type"`);
}
}
51 changes: 43 additions & 8 deletions harvest-finance/backend/src/farm-vaults/farm-vaults.dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,62 @@ describe('FarmVaultsController DTO validation (amount)', () => {
});

it('accepts numeric string when transform enabled', async () => {
const transformed = await pipe.transform({ amount: '100' }, { metatype: TestAmountDto as any, type: 'body' });
const transformed = await pipe.transform(
{ amount: '100' },
{ metatype: TestAmountDto as any, type: 'body' },
);
expect(typeof transformed.amount).toBe('number');
expect(transformed.amount).toBe(100);
});

it('rejects NaN', async () => {
await expect(pipe.transform({ amount: 'NaN' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: 'NaN' },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
});

it('rejects Infinity and -Infinity', async () => {
await expect(pipe.transform({ amount: 'Infinity' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(pipe.transform({ amount: '-Infinity' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: 'Infinity' },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: '-Infinity' },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
});

it('rejects non-numeric strings', async () => {
await expect(pipe.transform({ amount: 'abc' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: 'abc' },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
});

it('rejects empty / null / undefined', async () => {
await expect(pipe.transform({}, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(pipe.transform({ amount: null }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(pipe.transform({ amount: undefined }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException);
await expect(
pipe.transform({}, { metatype: TestAmountDto as any, type: 'body' }),
).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: null },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
await expect(
pipe.transform(
{ amount: undefined },
{ metatype: TestAmountDto as any, type: 'body' },
),
).rejects.toThrow(BadRequestException);
});
});
Loading
Loading