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
11 changes: 8 additions & 3 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { BadRequestException, Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/auth.guard';
import { AdminGuard } from './admin.guard';
Expand Down Expand Up @@ -41,10 +41,15 @@ export class AdminController {
@Get('escrows')
@ApiOperation({ summary: 'Escrow analytics: totals and status breakdown' })
@ApiResponse({ status: 200, type: EscrowAnalyticsDto })
@ApiResponse({ status: 400, description: 'Invalid limit parameter' })
@ApiResponse({ status: 401, description: 'Missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Caller is authenticated but not an admin address' })
getEscrowAnalytics(@Query('limit') limit = 50, @Query('cursor') cursor?: string) {
return this.adminService.getEscrowAnalytics(limit, cursor);
getEscrowAnalytics(@Query('limit') limit?: string | number, @Query('cursor') cursor?: string) {
const parsedLimit = limit !== undefined ? parseInt(String(limit), 10) : 50;
if (isNaN(parsedLimit) || parsedLimit <= 0) {
throw new BadRequestException('limit must be a positive integer');
}
return this.adminService.getEscrowAnalytics(parsedLimit, cursor);
}

@Get('gigs')
Expand Down
7 changes: 5 additions & 2 deletions backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { config, JWT_ALGORITHM } from '../config/env.config';
import { JWT_ALGORITHM, getJwtVerificationSecrets } from '../config/env.config';

export interface JwtPayload {
address: string;
Expand All @@ -17,7 +17,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.JWT_SECRET,
secretOrPublicKeyProvider: (_request: any, _rawJwtToken: string, done: any) => {
const secrets = getJwtVerificationSecrets();
done(null, secrets);
},
algorithms: [JWT_ALGORITHM],
});
}
Expand Down
32 changes: 16 additions & 16 deletions backend/src/common/idempotency/idempotency.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ describe('Idempotency integration — gig-like endpoint', () => {
const key = 'gig-idem-uuid-001';

const res1 = await request(app.getHttpServer())
.post('/v1/test-gigs')
.post('/test-gigs')
.set('Idempotency-Key', key)
.send(gigBody)
.expect(201);

const res2 = await request(app.getHttpServer())
.post('/v1/test-gigs')
.post('/test-gigs')
.set('Idempotency-Key', key)
.send(gigBody)
.expect(201);
Expand All @@ -126,13 +126,13 @@ describe('Idempotency integration — gig-like endpoint', () => {
const key = 'gig-idem-uuid-002';

await request(app.getHttpServer())
.post('/v1/test-gigs')
.post('/test-gigs')
.set('Idempotency-Key', key)
.send(gigBody)
.expect(201);

const res = await request(app.getHttpServer())
.post('/v1/test-gigs')
.post('/test-gigs')
.set('Idempotency-Key', key)
.send({ ...gigBody, title: 'DIFFERENT TITLE' })
.expect(422);
Expand All @@ -141,9 +141,9 @@ describe('Idempotency integration — gig-like endpoint', () => {
});

it('creates separate records when no Idempotency-Key is provided', async () => {
const res1 = await request(app.getHttpServer()).post('/v1/test-gigs').send(gigBody).expect(201);
const res1 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201);

const res2 = await request(app.getHttpServer()).post('/v1/test-gigs').send(gigBody).expect(201);
const res2 = await request(app.getHttpServer()).post('/test-gigs').send(gigBody).expect(201);

expect(res1.body.id).toBe('gig-1');
expect(res2.body.id).toBe('gig-2');
Expand Down Expand Up @@ -183,13 +183,13 @@ describe('Idempotency integration — escrow-like endpoint', () => {
const key = 'esc-idem-uuid-001';

const res1 = await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.set('Idempotency-Key', key)
.send(escrowBody)
.expect(201);

const res2 = await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.set('Idempotency-Key', key)
.send(escrowBody)
.expect(201);
Expand All @@ -202,26 +202,26 @@ describe('Idempotency integration — escrow-like endpoint', () => {
const key = 'esc-idem-uuid-002';

await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.set('Idempotency-Key', key)
.send(escrowBody)
.expect(201);

await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.set('Idempotency-Key', key)
.send({ ...escrowBody, amountXLM: '999' })
.expect(422);
});

it('creates separate records when no Idempotency-Key is provided', async () => {
const res1 = await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.send(escrowBody)
.expect(201);

const res2 = await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.send(escrowBody)
.expect(201);

Expand Down Expand Up @@ -257,7 +257,7 @@ describe('Idempotency integration — cross-endpoint key namespacing', () => {
const sharedKey = 'shared-key-across-endpoints';

const gigRes = await request(app.getHttpServer())
.post('/v1/test-gigs')
.post('/test-gigs')
.set('Idempotency-Key', sharedKey)
.send({
creator: 'GABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01',
Expand All @@ -267,7 +267,7 @@ describe('Idempotency integration — cross-endpoint key namespacing', () => {
.expect(201);

const escrowRes = await request(app.getHttpServer())
.post('/v1/test-escrows')
.post('/test-escrows')
.set('Idempotency-Key', sharedKey)
.send({
depositor: 'GDEP0001234567890DEP0001234567890DEP0001234567890DEP0001',
Expand Down Expand Up @@ -314,7 +314,7 @@ describe('Idempotency integration — concurrent duplicate requests', () => {

const responses = await Promise.all(
Array.from({ length: concurrency }, () =>
request(app.getHttpServer()).post('/v1/test-slow').set('Idempotency-Key', key).send(body),
request(app.getHttpServer()).post('/test-slow').set('Idempotency-Key', key).send(body),
),
);

Expand All @@ -329,7 +329,7 @@ describe('Idempotency integration — concurrent duplicate requests', () => {
// Once the winner finishes, a later retry replays the cached result
// instead of creating a second resource.
const retry = await request(app.getHttpServer())
.post('/v1/test-slow')
.post('/test-slow')
.set('Idempotency-Key', key)
.send(body)
.expect(201);
Expand Down
20 changes: 20 additions & 0 deletions backend/src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AuditService } from '../audit/audit.service';
import { config } from '../config/env.config';

const ESCROW_KEY_PREFIX = 'escrow:';
const MILESTONE_KEY_PREFIX = 'milestone:';
export const ESCROWS_INDEX_KEY = 'escrows:index';
const ESCROWS_BY_DEPOSITOR_PREFIX = 'escrows:by-depositor:';
const ESCROWS_BY_CONTRACT_PREFIX = 'escrow:contract:';
Expand Down Expand Up @@ -518,6 +519,21 @@ export class EscrowService implements OnModuleInit {
milestone.approver = signerAddress;
milestone.approvedAt = new Date().toISOString();

if (this.redis) {
try {
const results = await this.redis
.multi()
.set(this.milestoneKey(id), JSON.stringify(milestone))
.exec();
assertTransactionApplied(results);
return milestone;
} catch (err) {
this.rethrowIfInconsistent('approveMilestone', id, err);
this.logFallback('approveMilestone', err);
}
}

this.milestones.set(id, milestone);
return milestone;
}

Expand Down Expand Up @@ -552,6 +568,10 @@ export class EscrowService implements OnModuleInit {
return `${ESCROW_KEY_PREFIX}${id}`;
}

private milestoneKey(id: string): string {
return `${MILESTONE_KEY_PREFIX}${id}`;
}

private depositorKey(address: string): string {
return `${ESCROWS_BY_DEPOSITOR_PREFIX}${address}`;
}
Expand Down
16 changes: 8 additions & 8 deletions backend/src/security-headers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('Security Headers (Helmet)', () => {
});

it('returns standard Helmet security headers on API responses', async () => {
const res = await request(app.getHttpServer()).get('/v1/health').expect(200);
const res = await request(app.getHttpServer()).get('/health').expect(200);

expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['strict-transport-security']).toBeDefined();
Expand All @@ -59,7 +59,7 @@ describe('Security Headers (Helmet)', () => {
});

it('applies a strict CSP to API responses', async () => {
const res = await request(app.getHttpServer()).get('/v1/health').expect(200);
const res = await request(app.getHttpServer()).get('/health').expect(200);

const csp = res.headers['content-security-policy'];
expect(csp).toContain("default-src 'none'");
Expand All @@ -68,7 +68,7 @@ describe('Security Headers (Helmet)', () => {
});

it('applies the relaxed Swagger CSP only on /api/docs', async () => {
const res = await request(app.getHttpServer()).get('/v1/api/docs/').expect(200);
const res = await request(app.getHttpServer()).get('/api/docs/').expect(200);

expect(res.text).toContain('swagger-ui');
expect(res.headers['x-content-type-options']).toBe('nosniff');
Expand All @@ -93,8 +93,8 @@ describe('docs availability and protection', () => {
}).compile();
const app = moduleFixture.createNestApplication();
await app.init();
await request(app.getHttpServer()).get('/v1/api/docs').expect(404);
await request(app.getHttpServer()).get('/v1/api/docs-json').expect(404);
await request(app.getHttpServer()).get('/api/docs').expect(404);
await request(app.getHttpServer()).get('/api/docs-json').expect(404);
await app.close();
});

Expand All @@ -110,9 +110,9 @@ describe('docs availability and protection', () => {
SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: '/api/docs-json' });
await app.init();
const server = app.getHttpServer();
await request(server).get('/v1/api/docs-json').expect(401);
await request(server).get('/v1/api/docs-json').auth('u', 'wrong').expect(401);
await request(server).get('/v1/api/docs-json').auth('u', 'p').expect(200);
await request(server).get('/api/docs-json').expect(401);
await request(server).get('/api/docs-json').auth('u', 'wrong').expect(401);
await request(server).get('/api/docs-json').auth('u', 'p').expect(200);
await app.close();
});

Expand Down