diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index a4482af..c9de3f7 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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'; @@ -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') diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index e4bfad5..602ac80 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -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; @@ -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], }); } diff --git a/backend/src/common/idempotency/idempotency.integration.spec.ts b/backend/src/common/idempotency/idempotency.integration.spec.ts index 506ef8e..f84998d 100644 --- a/backend/src/common/idempotency/idempotency.integration.spec.ts +++ b/backend/src/common/idempotency/idempotency.integration.spec.ts @@ -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); @@ -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); @@ -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'); @@ -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); @@ -202,13 +202,13 @@ 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); @@ -216,12 +216,12 @@ describe('Idempotency integration — escrow-like endpoint', () => { 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); @@ -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', @@ -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', @@ -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), ), ); @@ -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); diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 2dffdf4..c6684fa 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -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:'; @@ -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; } @@ -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}`; } diff --git a/backend/src/security-headers.spec.ts b/backend/src/security-headers.spec.ts index 4fddf01..8e3e6ac 100644 --- a/backend/src/security-headers.spec.ts +++ b/backend/src/security-headers.spec.ts @@ -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(); @@ -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'"); @@ -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'); @@ -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(); }); @@ -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(); });