diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8610e4a63..dda263a47 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.i import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; import { RequestLoggingInterceptor } from './common/interceptors/request-logging.interceptor'; import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor'; +import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard'; import { CommonModule } from './common/common.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; @@ -374,6 +375,10 @@ const envValidationSchema = Joi.object({ provide: APP_INTERCEPTOR, useClass: GracefulShutdownInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/decorators/idempotent.decorator.ts b/backend/src/common/decorators/idempotent.decorator.ts new file mode 100644 index 000000000..33c2c1433 --- /dev/null +++ b/backend/src/common/decorators/idempotent.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IDEMPOTENCY_KEY = 'idempotency'; + +export interface IdempotencyOptions { + ttlSeconds?: number; +} + +export const Idempotent = (options?: IdempotencyOptions) => + SetMetadata(IDEMPOTENCY_KEY, { ttlSeconds: options?.ttlSeconds ?? 86400 }); diff --git a/backend/src/common/dto/api-error-response.dto.ts b/backend/src/common/dto/api-error-response.dto.ts index 681942d41..dca964f9a 100644 --- a/backend/src/common/dto/api-error-response.dto.ts +++ b/backend/src/common/dto/api-error-response.dto.ts @@ -1,16 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; import { StandardErrorResponseDto } from './standard-error-response.dto'; +import { ErrorCode } from '../enums/error-code.enum'; export class ApiErrorResponseDto extends StandardErrorResponseDto { @ApiProperty({ - example: 'Bad Request', - description: 'Error type (deprecated, use errorCode instead)', - required: false, + enum: ErrorCode, + example: ErrorCode.BAD_REQUEST, + description: 'Stable, machine-readable error code', }) - error?: string; + declare errorCode: ErrorCode; } export class ValidationErrorDto extends ApiErrorResponseDto { + @ApiProperty({ example: 422 }) + statusCode = 422; + + @ApiProperty({ enum: [ErrorCode.VALIDATION_ERROR] }) + errorCode = ErrorCode.VALIDATION_ERROR; + @ApiProperty({ example: [ { @@ -21,7 +28,7 @@ export class ValidationErrorDto extends ApiErrorResponseDto { }, }, ], - description: 'Validation errors', + description: 'Per-field validation errors', }) declare errors?: Array<{ field: string; @@ -31,61 +38,33 @@ export class ValidationErrorDto extends ApiErrorResponseDto { } export class UnauthorizedErrorDto extends ApiErrorResponseDto { - @ApiProperty({ - example: 401, - description: 'HTTP status code', - }) + @ApiProperty({ example: 401 }) statusCode = 401; - @ApiProperty({ - example: 'AUTH_001', - description: 'Error code', - }) - errorCode = 'AUTH_001'; - - @ApiProperty({ - example: 'Authentication required. Please provide valid credentials.', - description: 'Error message', - }) - message = 'Authentication required. Please provide valid credentials.'; + @ApiProperty({ enum: [ErrorCode.UNAUTHORIZED] }) + errorCode = ErrorCode.UNAUTHORIZED; } export class ForbiddenErrorDto extends ApiErrorResponseDto { - @ApiProperty({ - example: 403, - description: 'HTTP status code', - }) + @ApiProperty({ example: 403 }) statusCode = 403; - @ApiProperty({ - example: 'AUTHZ_001', - description: 'Error code', - }) - errorCode = 'AUTHZ_001'; - - @ApiProperty({ - example: 'You do not have permission to access this resource.', - description: 'Error message', - }) - message = 'You do not have permission to access this resource.'; + @ApiProperty({ enum: [ErrorCode.FORBIDDEN] }) + errorCode = ErrorCode.FORBIDDEN; } export class NotFoundErrorDto extends ApiErrorResponseDto { - @ApiProperty({ - example: 404, - description: 'HTTP status code', - }) + @ApiProperty({ example: 404 }) statusCode = 404; - @ApiProperty({ - example: 'SYS_404', - description: 'Error code', - }) - errorCode = 'SYS_404'; + @ApiProperty({ enum: [ErrorCode.NOT_FOUND] }) + errorCode = ErrorCode.NOT_FOUND; +} - @ApiProperty({ - example: 'The requested resource was not found.', - description: 'Error message', - }) - message = 'The requested resource was not found.'; +export class ConflictErrorDto extends ApiErrorResponseDto { + @ApiProperty({ example: 409 }) + statusCode = 409; + + @ApiProperty({ enum: [ErrorCode.CONFLICT] }) + errorCode = ErrorCode.CONFLICT; } diff --git a/backend/src/common/enums/error-code.enum.ts b/backend/src/common/enums/error-code.enum.ts new file mode 100644 index 000000000..1d2da47b1 --- /dev/null +++ b/backend/src/common/enums/error-code.enum.ts @@ -0,0 +1,63 @@ +export enum ErrorCode { + // Generic + INTERNAL_ERROR = 'INTERNAL_ERROR', + VALIDATION_ERROR = 'VALIDATION_ERROR', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', + TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', + BAD_REQUEST = 'BAD_REQUEST', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + + // Auth + INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', + TOKEN_EXPIRED = 'TOKEN_EXPIRED', + SESSION_EXPIRED = 'SESSION_EXPIRED', + + // Savings + SAVINGS_PRODUCT_NOT_FOUND = 'SAVINGS_PRODUCT_NOT_FOUND', + SAVINGS_GOAL_NOT_FOUND = 'SAVINGS_GOAL_NOT_FOUND', + SUBSCRIPTION_NOT_FOUND = 'SUBSCRIPTION_NOT_FOUND', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + WITHDRAWAL_NOT_FOUND = 'WITHDRAWAL_NOT_FOUND', + DEPOSIT_FAILED = 'DEPOSIT_FAILED', + WITHDRAWAL_FAILED = 'WITHDRAWAL_FAILED', + MINIMUM_AMOUNT_NOT_MET = 'MINIMUM_AMOUNT_NOT_MET', + + // Governance + PROPOSAL_NOT_FOUND = 'PROPOSAL_NOT_FOUND', + INVALID_GOVERNANCE_STATE = 'INVALID_GOVERNANCE_STATE', + ALREADY_VOTED = 'ALREADY_VOTED', + NO_VOTING_POWER = 'NO_VOTING_POWER', + PROPOSAL_NOT_ACTIVE = 'PROPOSAL_NOT_ACTIVE', + + // Transactions + TRANSACTION_NOT_FOUND = 'TRANSACTION_NOT_FOUND', + DUPLICATE_TRANSACTION = 'DUPLICATE_TRANSACTION', + INVALID_TRANSACTION_STATE = 'INVALID_TRANSACTION_STATE', + + // Disputes + DISPUTE_NOT_FOUND = 'DISPUTE_NOT_FOUND', + INVALID_DISPUTE_STATE = 'INVALID_DISPUTE_STATE', + + // Claims + CLAIM_NOT_FOUND = 'CLAIM_NOT_FOUND', + INVALID_CLAIM_STATE = 'INVALID_CLAIM_STATE', + + // Referrals + REFERRAL_NOT_FOUND = 'REFERRAL_NOT_FOUND', + SELF_REFERRAL = 'SELF_REFERRAL', + REFERRAL_ALREADY_USED = 'REFERRAL_ALREADY_USED', + + // Blockchain / RPC + SOROBAN_RPC_TIMEOUT = 'SOROBAN_RPC_TIMEOUT', + SOROBAN_RPC_EXHAUSTED = 'SOROBAN_RPC_EXHAUSTED', + BLOCKCHAIN_ERROR = 'BLOCKCHAIN_ERROR', + + // Database + DB_CONNECTION_ERROR = 'DB_CONNECTION_ERROR', + + // Idempotency + IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT', +} diff --git a/backend/src/common/exceptions/domain.exception.ts b/backend/src/common/exceptions/domain.exception.ts new file mode 100644 index 000000000..c1e942c17 --- /dev/null +++ b/backend/src/common/exceptions/domain.exception.ts @@ -0,0 +1,66 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ErrorCode } from '../enums/error-code.enum'; + +export interface DomainExceptionOptions { + errorCode: ErrorCode; + message: string; + statusCode?: HttpStatus; + details?: Record | Array>; + docsUrl?: string; +} + +export class DomainException extends HttpException { + public readonly errorCode: ErrorCode; + public readonly details?: + | Record + | Array>; + public readonly docsUrl?: string; + + constructor(options: DomainExceptionOptions) { + const status = options.statusCode ?? HttpStatus.BAD_REQUEST; + super( + { + errorCode: options.errorCode, + message: options.message, + details: options.details, + docsUrl: options.docsUrl, + }, + status, + ); + this.errorCode = options.errorCode; + this.details = options.details; + this.docsUrl = options.docsUrl; + } +} + +export class InsufficientFundsException extends DomainException { + constructor(details?: Record) { + super({ + errorCode: ErrorCode.INSUFFICIENT_BALANCE, + message: 'Insufficient balance to complete this operation', + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + details, + }); + } +} + +export class InvalidGovernanceStateException extends DomainException { + constructor(message: string, details?: Record) { + super({ + errorCode: ErrorCode.INVALID_GOVERNANCE_STATE, + message, + statusCode: HttpStatus.CONFLICT, + details, + }); + } +} + +export class ResourceNotFoundException extends DomainException { + constructor(resource: string, id?: string) { + super({ + errorCode: ErrorCode.NOT_FOUND, + message: id ? `${resource} '${id}' not found` : `${resource} not found`, + statusCode: HttpStatus.NOT_FOUND, + }); + } +} diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index e20ade73f..42f2d3231 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -7,11 +7,21 @@ import { Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; +import { ErrorCode } from '../enums/error-code.enum'; +import { DomainException } from '../exceptions/domain.exception'; + +interface StandardErrorResponse { + success: false; + statusCode: number; + errorCode: ErrorCode; + message: string; + details?: Record | Array>; + requestId: string | null; + timestamp: string; + path: string; + docsUrl?: string; +} -/** - * Patterns emitted by RpcClientWrapper that indicate a Soroban/Horizon - * RPC timeout or full-endpoint-exhaustion event. - */ const RPC_TIMEOUT_PATTERN = /request timeout after \d+ms/i; const RPC_EXHAUSTED_PATTERN = /all \w+ rpc endpoints failed/i; const DB_CONNECTION_PATTERNS = [ @@ -24,7 +34,16 @@ const DB_CONNECTION_PATTERNS = [ /connect etimedout/i, ]; -/** Classify an unknown exception as an RPC-layer error. */ +const STATUS_TO_ERROR_CODE: Record = { + [HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST, + [HttpStatus.UNAUTHORIZED]: ErrorCode.UNAUTHORIZED, + [HttpStatus.FORBIDDEN]: ErrorCode.FORBIDDEN, + [HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND, + [HttpStatus.CONFLICT]: ErrorCode.CONFLICT, + [HttpStatus.TOO_MANY_REQUESTS]: ErrorCode.TOO_MANY_REQUESTS, + [HttpStatus.SERVICE_UNAVAILABLE]: ErrorCode.SERVICE_UNAVAILABLE, +}; + function isRpcFallbackError(exception: unknown): exception is Error { if (!(exception instanceof Error)) return false; return ( @@ -35,10 +54,8 @@ function isRpcFallbackError(exception: unknown): exception is Error { function isDatabaseConnectionError(exception: unknown): exception is Error { if (!(exception instanceof Error)) return false; - const message = exception.message || ''; const code = (exception as Error & { code?: string }).code || ''; - return ( DB_CONNECTION_PATTERNS.some((pattern) => pattern.test(message)) || [ @@ -52,129 +69,182 @@ function isDatabaseConnectionError(exception: unknown): exception is Error { ); } +function extractValidationDetails( + exceptionResponse: Record, +): Array> | undefined { + const msg = exceptionResponse.message; + if (!Array.isArray(msg)) return undefined; + + return msg.map((m: string) => { + const field = m.split(' ')[0]; + return { field, message: m }; + }); +} + @Catch() export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger(AllExceptionsFilter.name); + private readonly isProduction = process.env.NODE_ENV === 'production'; catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); + const requestId = + ((request as unknown as Record).correlationId as + | string + | undefined) ?? + (request.headers['x-correlation-id'] as string) ?? + null; + const timestamp = new Date().toISOString(); + + // ── Domain exceptions (with stable errorCode) ─────────────────────────── + if (exception instanceof DomainException) { + const status = exception.getStatus(); + const body: StandardErrorResponse = { + success: false, + statusCode: status, + errorCode: exception.errorCode, + message: exception.message, + details: exception.details, + requestId, + timestamp, + path: request.url, + docsUrl: exception.docsUrl, + }; + + this.logError(request, status, exception); + return response.status(status).json(body); + } - // ── RPC / Blockchain layer errors ──────────────────────────────────────── + // ── RPC / Blockchain layer errors ─────────────────────────────────────── if (isRpcFallbackError(exception)) { const isTimeout = RPC_TIMEOUT_PATTERN.test(exception.message); const httpStatus = isTimeout - ? HttpStatus.GATEWAY_TIMEOUT // 504 – a single endpoint timed out - : HttpStatus.SERVICE_UNAVAILABLE; // 503 – all fallbacks exhausted - - this.logger.error( - `[RPC Fallback] ${request.method} ${request.url} → ${httpStatus} | ${exception.message}`, - exception.stack, - { - errorCode: isTimeout - ? 'SOROBAN_RPC_TIMEOUT' - : 'SOROBAN_RPC_EXHAUSTED', - path: request.url, - method: request.method, - timestamp: new Date().toISOString(), - }, - ); + ? HttpStatus.GATEWAY_TIMEOUT + : HttpStatus.SERVICE_UNAVAILABLE; + const errorCode = isTimeout + ? ErrorCode.SOROBAN_RPC_TIMEOUT + : ErrorCode.SOROBAN_RPC_EXHAUSTED; - return response.status(httpStatus).json({ + const body: StandardErrorResponse = { success: false, statusCode: httpStatus, - correlationId: - (request as Request & { correlationId?: string }).correlationId, - errorCode: isTimeout ? 'SOROBAN_RPC_TIMEOUT' : 'SOROBAN_RPC_EXHAUSTED', - timestamp: new Date().toISOString(), - path: request.url, + errorCode, message: isTimeout ? 'Soroban RPC request timed out. The network may be under load.' : 'All Soroban RPC endpoints are currently unavailable. Please retry later.', - }); - } - - // ── Database connectivity errors ──────────────────────────────────────── - if (isDatabaseConnectionError(exception)) { - const statusCode = HttpStatus.SERVICE_UNAVAILABLE; + requestId, + timestamp, + path: request.url, + }; this.logger.error( - `[DB Connection] ${request.method} ${request.url} → ${statusCode} | ${exception.message}`, + `[RPC Fallback] ${request.method} ${request.url} → ${httpStatus}`, exception.stack, + { errorCode, requestId }, ); + return response.status(httpStatus).json(body); + } - return response.status(statusCode).json({ + // ── Database connectivity errors ──────────────────────────────────────── + if (isDatabaseConnectionError(exception)) { + const body: StandardErrorResponse = { success: false, - statusCode, - correlationId: - (request as Request & { correlationId?: string }).correlationId, - errorCode: 'DB_CONNECTION_ERROR', - timestamp: new Date().toISOString(), - path: request.url, + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + errorCode: ErrorCode.DB_CONNECTION_ERROR, message: 'Database connection is currently unavailable. Please try again shortly.', - }); + requestId, + timestamp, + path: request.url, + }; + + this.logger.error( + `[DB Connection] ${request.method} ${request.url} → 503`, + exception.stack, + ); + return response.status(HttpStatus.SERVICE_UNAVAILABLE).json(body); } - // ── Standard HTTP exceptions ───────────────────────────────────────────── + // ── Standard HTTP exceptions ──────────────────────────────────────────── const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; let message: string; - let errors: any[] = []; + let details: Array> | undefined; + let errorCode: ErrorCode = + STATUS_TO_ERROR_CODE[status] ?? ErrorCode.INTERNAL_ERROR; + if (exception instanceof HttpException) { const exceptionResponse = exception.getResponse(); + if (typeof exceptionResponse === 'string') { message = exceptionResponse; } else if ( typeof exceptionResponse === 'object' && exceptionResponse !== null ) { - const responseData = exceptionResponse as Record; - const msg = responseData.message; - message = Array.isArray(msg) - ? msg.join('; ') - : String(msg ?? 'An error occurred'); - - if (Array.isArray(responseData.errors)) { - errors = responseData.errors; + const resp = exceptionResponse as Record; + + if (resp.errorCode && typeof resp.errorCode === 'string') { + errorCode = resp.errorCode as ErrorCode; + } + + const msg = resp.message; + if (Array.isArray(msg)) { + message = 'Validation failed'; + errorCode = ErrorCode.VALIDATION_ERROR; + details = extractValidationDetails(resp); + } else { + message = String(msg ?? 'An error occurred'); } } else { message = 'An error occurred'; } } else { - message = status >= 500 ? 'Internal server error' : 'An error occurred'; + message = this.isProduction + ? 'Internal server error' + : exception instanceof Error + ? exception.message + : 'Internal server error'; } - const errorResponse: Record = { + const body: StandardErrorResponse = { success: false, statusCode: status, - correlationId: - (request as Request & { correlationId?: string }).correlationId ?? - (request.headers['x-correlation-id'] as string) ?? - undefined, - timestamp: new Date().toISOString(), + errorCode, + message, + details, + requestId, + timestamp, path: request.url, - message: - typeof message === 'object' && message !== null - ? (message as { message?: string }).message - : message, }; - if (errors.length > 0) { - errorResponse.errors = errors; - } + this.logError(request, status, exception); + response.status(status).json(body); + } + + private logError( + request: Request, + status: number, + exception: unknown, + ): void { + const msg = + exception instanceof Error ? exception.message : String(exception); + const stack = exception instanceof Error ? exception.stack : undefined; if (status >= 500) { this.logger.error( - `HTTP ${status} ${request.method} ${request.url} - ${message}`, - exception instanceof Error ? exception.stack : '', + `HTTP ${status} ${request.method} ${request.url} - ${msg}`, + stack, + ); + } else if (status >= 400) { + this.logger.warn( + `HTTP ${status} ${request.method} ${request.url} - ${msg}`, ); } - - response.status(status).json(errorResponse); } } diff --git a/backend/src/common/interceptors/idempotency.interceptor.spec.ts b/backend/src/common/interceptors/idempotency.interceptor.spec.ts index 5ceca8906..46dd6ba96 100644 --- a/backend/src/common/interceptors/idempotency.interceptor.spec.ts +++ b/backend/src/common/interceptors/idempotency.interceptor.spec.ts @@ -2,135 +2,137 @@ import { ExecutionContext, CallHandler, ConflictException, - BadRequestException, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { IdempotencyInterceptor } from './idempotency.interceptor'; -import { IdempotencyService } from '../services/idempotency.service'; +import { IDEMPOTENCY_KEY } from '../decorators/idempotent.decorator'; import { of, throwError, firstValueFrom } from 'rxjs'; describe('IdempotencyInterceptor', () => { let interceptor: IdempotencyInterceptor; - let idempotencyService: jest.Mocked; + let reflector: Reflector; + let cache: Record; + + const mockCache = { + get: jest.fn(async (key: string) => cache[key] ?? null), + set: jest.fn(async (key: string, value: unknown) => { + cache[key] = value; + }), + del: jest.fn(async (key: string) => { + delete cache[key]; + }), + }; beforeEach(() => { - idempotencyService = { - getResponse: jest.fn(), - saveResponse: jest.fn(), - isProcessing: jest.fn(), - setProcessing: jest.fn(), - removeProcessing: jest.fn(), - } as any; - - interceptor = new IdempotencyInterceptor(idempotencyService); + cache = {}; + reflector = new Reflector(); + interceptor = new IdempotencyInterceptor(reflector, mockCache as any); + jest.clearAllMocks(); }); const createMockContext = ( method: string, - headers: any, - user: any = { id: 'user1' }, + path: string, + headers: Record, + body: unknown = {}, + handlerFn?: Function, ): ExecutionContext => ({ switchToHttp: () => ({ - getRequest: () => ({ - method, - headers, - user, + getRequest: () => ({ method, path, headers, body }), + getResponse: () => ({ + statusCode: 200, + setHeader: jest.fn(), + status: jest.fn(), }), }), + getHandler: () => handlerFn ?? (() => {}), }) as any; - const mockCallHandler: CallHandler = { - handle: () => of({ success: true }), - }; + it('should skip when no @Idempotent decorator is present', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + const context = createMockContext('POST', '/test', { 'idempotency-key': 'k1' }); + const next = { handle: jest.fn().mockReturnValue(of({ ok: true })) }; - it('should skip if not a mutation method (GET)', async () => { - const context = createMockContext('GET', { 'x-idempotency-key': 'key1' }); - const next = { handle: jest.fn().mockReturnValue(of({ data: 'ok' })) }; + const result$ = await interceptor.intercept(context, next); + expect(result$).toBe(next.handle()); + expect(mockCache.get).not.toHaveBeenCalled(); + }); - await interceptor.intercept(context, next); + it('should skip when no idempotency-key header is provided', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ ttlSeconds: 3600 }); + const context = createMockContext('POST', '/test', {}); + const next = { handle: jest.fn().mockReturnValue(of({ ok: true })) }; + await interceptor.intercept(context, next); expect(next.handle).toHaveBeenCalled(); - expect(idempotencyService.getResponse).not.toHaveBeenCalled(); }); - it('should skip if no idempotency key is provided', async () => { - const context = createMockContext('POST', {}); - const next = { handle: jest.fn().mockReturnValue(of({ data: 'ok' })) }; + it('should return cached response on idempotency hit', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ ttlSeconds: 3600 }); - await interceptor.intercept(context, next); + const payloadHash = require('crypto') + .createHash('sha256') + .update(JSON.stringify({})) + .digest('hex'); - expect(next.handle).toHaveBeenCalled(); - expect(idempotencyService.getResponse).not.toHaveBeenCalled(); - }); + cache['idempotency:POST:/test:k1'] = { + payloadHash, + statusCode: 201, + body: { id: '123' }, + completedAt: new Date().toISOString(), + }; - it('should return cached response if key exists', (done) => { - const context = createMockContext('POST', { 'x-idempotency-key': 'key1' }); - const cachedResponse = { success: true, fromCache: true }; - idempotencyService.getResponse.mockResolvedValue(cachedResponse); - - interceptor.intercept(context, mockCallHandler).then((result$) => { - result$.subscribe((response) => { - expect(response).toEqual(cachedResponse); - expect(idempotencyService.getResponse).toHaveBeenCalledWith( - 'key1', - 'user1', - ); - done(); - }); - }); + const context = createMockContext('POST', '/test', { 'idempotency-key': 'k1' }); + const next: CallHandler = { handle: () => of({ shouldNotReturn: true }) }; + + const result$ = await interceptor.intercept(context, next); + const result = await firstValueFrom(result$); + expect(result).toEqual({ id: '123' }); }); - it('should throw ConflictException if request is already being processed', async () => { - const context = createMockContext('POST', { 'x-idempotency-key': 'key1' }); - idempotencyService.getResponse.mockResolvedValue(null); - idempotencyService.isProcessing.mockResolvedValue(true); + it('should return 409 when same key is used with different payload', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ ttlSeconds: 3600 }); + + cache['idempotency:POST:/test:k1'] = { + payloadHash: 'different-hash', + statusCode: 201, + body: { id: '123' }, + completedAt: new Date().toISOString(), + }; + + const context = createMockContext('POST', '/test', { 'idempotency-key': 'k1' }); + const next: CallHandler = { handle: () => of({}) }; - await expect( - interceptor.intercept(context, mockCallHandler), - ).rejects.toThrow(ConflictException); + const result$ = await interceptor.intercept(context, next); + await expect(firstValueFrom(result$)).rejects.toThrow(ConflictException); }); - it('should process request and cache response if key is new', async () => { - const context = createMockContext('POST', { 'x-idempotency-key': 'key1' }); - idempotencyService.getResponse.mockResolvedValue(null); - idempotencyService.isProcessing.mockResolvedValue(false); - - await firstValueFrom(await interceptor.intercept(context, mockCallHandler)); - await Promise.resolve(); - - expect(idempotencyService.setProcessing).toHaveBeenCalledWith( - 'key1', - 'user1', - ); - expect(idempotencyService.saveResponse).toHaveBeenCalledWith( - 'key1', - 'user1', - { success: true }, - ); - expect(idempotencyService.removeProcessing).toHaveBeenCalledWith( - 'key1', - 'user1', - ); + it('should process and cache a new request', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ ttlSeconds: 3600 }); + + const context = createMockContext('POST', '/test', { 'idempotency-key': 'k2' }); + const next: CallHandler = { handle: () => of({ created: true }) }; + + const result$ = await interceptor.intercept(context, next); + const result = await firstValueFrom(result$); + + expect(result).toEqual({ created: true }); + await new Promise((r) => setTimeout(r, 10)); + expect(mockCache.set).toHaveBeenCalled(); }); - it('should remove processing lock even if request fails', async () => { - const context = createMockContext('POST', { 'x-idempotency-key': 'key1' }); - idempotencyService.getResponse.mockResolvedValue(null); - idempotencyService.isProcessing.mockResolvedValue(false); + it('should release lock on error', async () => { + jest.spyOn(reflector, 'get').mockReturnValue({ ttlSeconds: 3600 }); - const failingHandler: CallHandler = { - handle: () => throwError(() => new Error('API Error')), + const context = createMockContext('POST', '/test', { 'idempotency-key': 'k3' }); + const next: CallHandler = { + handle: () => throwError(() => new Error('boom')), }; - await expect( - firstValueFrom(await interceptor.intercept(context, failingHandler)), - ).rejects.toThrow('API Error'); - await Promise.resolve(); - - expect(idempotencyService.removeProcessing).toHaveBeenCalledWith( - 'key1', - 'user1', - ); - expect(idempotencyService.saveResponse).not.toHaveBeenCalled(); + const result$ = await interceptor.intercept(context, next); + await expect(firstValueFrom(result$)).rejects.toThrow('boom'); + await new Promise((r) => setTimeout(r, 10)); + expect(mockCache.del).toHaveBeenCalled(); }); }); diff --git a/backend/src/common/interceptors/idempotency.interceptor.ts b/backend/src/common/interceptors/idempotency.interceptor.ts index 4d8b39f3c..cb61262dc 100644 --- a/backend/src/common/interceptors/idempotency.interceptor.ts +++ b/backend/src/common/interceptors/idempotency.interceptor.ts @@ -4,75 +4,143 @@ import { ExecutionContext, CallHandler, ConflictException, - BadRequestException, + Logger, + Inject, } from '@nestjs/common'; -import { Observable, of, from } from 'rxjs'; -import { tap, mergeMap, finalize } from 'rxjs/operators'; -import { IdempotencyService } from '../services/idempotency.service'; +import { Reflector } from '@nestjs/core'; +import { Observable, of, throwError } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { Request, Response } from 'express'; +import { createHash } from 'crypto'; +import { + IDEMPOTENCY_KEY, + IdempotencyOptions, +} from '../decorators/idempotent.decorator'; +import { ErrorCode } from '../enums/error-code.enum'; + +interface StoredIdempotencyRecord { + payloadHash: string; + statusCode: number; + body: unknown; + completedAt: string; +} + +const LOCK_SUFFIX = ':lock'; +const LOCK_TTL_MS = 30_000; @Injectable() export class IdempotencyInterceptor implements NestInterceptor { - constructor(private readonly idempotencyService: IdempotencyService) {} + private readonly logger = new Logger(IdempotencyInterceptor.name); + + constructor( + private readonly reflector: Reflector, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} async intercept( context: ExecutionContext, next: CallHandler, - ): Promise> { - const request = context.switchToHttp().getRequest(); - - // Only apply to POST, PATCH, PUT, DELETE - if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method)) { + ): Promise> { + const options = this.reflector.get( + IDEMPOTENCY_KEY, + context.getHandler(), + ); + + if (!options) { return next.handle(); } - const idempotencyKey = request.headers['x-idempotency-key']; + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const idempotencyKey = request.headers['idempotency-key'] as + | string + | undefined; if (!idempotencyKey) { return next.handle(); } - if (typeof idempotencyKey !== 'string') { - throw new BadRequestException('Invalid X-Idempotency-Key header'); - } + const cacheKey = `idempotency:${request.method}:${request.path}:${idempotencyKey}`; + const payloadHash = this.hashPayload(request.body); - const userId = request.user?.id || 'anonymous'; - - // Check if we have a cached response - const cachedResponse = await this.idempotencyService.getResponse( - idempotencyKey, - userId, - ); - if (cachedResponse) { - return of(cachedResponse); + const existing = await this.cache.get(cacheKey); + + if (existing) { + if (existing.payloadHash !== payloadHash) { + return throwError( + () => + new ConflictException({ + errorCode: ErrorCode.IDEMPOTENCY_CONFLICT, + message: + 'Idempotency key has already been used with a different request payload', + }), + ); + } + + this.logger.debug( + `Idempotency cache hit for key=${idempotencyKey} on ${request.method} ${request.path}`, + ); + response.setHeader('Idempotency-Replay', 'true'); + response.status(existing.statusCode); + return of(existing.body); } - // Check if it's already being processed to prevent race conditions - const isProcessing = await this.idempotencyService.isProcessing( - idempotencyKey, - userId, - ); - if (isProcessing) { - throw new ConflictException( - 'A request with this idempotency key is already being processed', + const lockKey = `${cacheKey}${LOCK_SUFFIX}`; + const lockAcquired = await this.tryAcquireLock(lockKey); + + if (!lockAcquired) { + return throwError( + () => + new ConflictException({ + errorCode: ErrorCode.IDEMPOTENCY_CONFLICT, + message: + 'A request with this idempotency key is currently being processed', + }), ); } - // Mark as processing - await this.idempotencyService.setProcessing(idempotencyKey, userId); + const ttlMs = (options.ttlSeconds ?? 86400) * 1000; return next.handle().pipe( - tap(async (response) => { - // Cache the successful response - await this.idempotencyService.saveResponse( - idempotencyKey, - userId, - response, - ); + tap(async (body) => { + try { + const record: StoredIdempotencyRecord = { + payloadHash, + statusCode: response.statusCode, + body, + completedAt: new Date().toISOString(), + }; + await this.cache.set(cacheKey, record, ttlMs); + } finally { + await this.releaseLock(lockKey); + } }), - finalize(async () => { - // Remove processing lock - await this.idempotencyService.removeProcessing(idempotencyKey, userId); + catchError(async (err) => { + await this.releaseLock(lockKey); + throw err; }), ); } + + private hashPayload(body: unknown): string { + const normalized = JSON.stringify(body ?? {}); + return createHash('sha256').update(normalized).digest('hex'); + } + + private async tryAcquireLock(lockKey: string): Promise { + const existing = await this.cache.get(lockKey); + if (existing) return false; + await this.cache.set(lockKey, '1', LOCK_TTL_MS); + return true; + } + + private async releaseLock(lockKey: string): Promise { + try { + await this.cache.del(lockKey); + } catch { + // Lock cleanup is best-effort + } + } } diff --git a/backend/src/modules/governance/governance-proposals.controller.ts b/backend/src/modules/governance/governance-proposals.controller.ts index 143d60b49..95d510ba0 100644 --- a/backend/src/modules/governance/governance-proposals.controller.ts +++ b/backend/src/modules/governance/governance-proposals.controller.ts @@ -30,6 +30,7 @@ import { ProposalTemplateSummaryDto } from './dto/proposal-template-summary.dto' import { ProposalVotesResponseDto } from './dto/proposal-votes-response.dto'; import { ProposalStatus } from './entities/governance-proposal.entity'; import { GovernanceService } from './governance.service'; +import { Idempotent } from '../../common/decorators/idempotent.decorator'; @ApiTags('governance') @Controller('governance/proposals') @@ -147,6 +148,7 @@ export class GovernanceProposalsController { @Post(':id/vote') @UseGuards(JwtAuthGuard) + @Idempotent({ ttlSeconds: 3600 }) @ApiBearerAuth() @ApiOperation({ summary: 'Cast a vote on an active proposal', @@ -225,6 +227,7 @@ export class GovernanceProposalsController { @Post(':id/execute') @UseGuards(JwtAuthGuard) + @Idempotent({ ttlSeconds: 86400 }) @ApiBearerAuth() @ApiOperation({ summary: 'Execute a queued proposal after timelock' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index c1d43845f..a5e1ee068 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -52,6 +52,7 @@ import { RecommendationResponseDto } from './dto/recommendation-response.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard'; +import { Idempotent } from '../../common/decorators/idempotent.decorator'; import { RecommendationService } from './services/recommendation.service'; import { AutoDepositService } from './services/auto-deposit.service'; import { IdempotencyInterceptor } from '../../common/interceptors/idempotency.interceptor'; @@ -220,6 +221,7 @@ export class SavingsController { @UseGuards(JwtAuthGuard) @UseInterceptors(IdempotencyInterceptor) @HttpCode(HttpStatus.CREATED) + @Idempotent({ ttlSeconds: 86400 }) @ApiBearerAuth() @ApiOperation({ summary: 'Subscribe to a savings product' }) @ApiBody({ type: SubscribeDto }) @@ -245,6 +247,7 @@ export class SavingsController { @UseGuards(JwtAuthGuard) @UseInterceptors(IdempotencyInterceptor) @HttpCode(HttpStatus.CREATED) + @Idempotent({ ttlSeconds: 86400 }) @ApiBearerAuth() @ApiOperation({ summary: 'Request withdrawal from a savings subscription', diff --git a/backend/src/modules/webhooks/stellar-webhook.controller.ts b/backend/src/modules/webhooks/stellar-webhook.controller.ts index 5643a81e6..9239efe3d 100644 --- a/backend/src/modules/webhooks/stellar-webhook.controller.ts +++ b/backend/src/modules/webhooks/stellar-webhook.controller.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; +import { Idempotent } from '../../common/decorators/idempotent.decorator'; @Controller('webhooks/stellar') export class StellarWebhookController { @@ -19,6 +20,7 @@ export class StellarWebhookController { @Post() @HttpCode(HttpStatus.OK) + @Idempotent({ ttlSeconds: 86400 }) async handleWebhook( @Body() payload: any, @Headers('x-stellar-signature') signature?: string, diff --git a/backend/test/error-responses.e2e-spec.ts b/backend/test/error-responses.e2e-spec.ts new file mode 100644 index 000000000..34bdf7867 --- /dev/null +++ b/backend/test/error-responses.e2e-spec.ts @@ -0,0 +1,136 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/http-exception.filter'; + +describe('Standardized Error Responses E2E', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should have consistent 404 shape', async () => { + const res = await request(app.getHttpServer()) + .get('/api/nonexistent-route') + .expect(404); + + expect(res.body).toMatchObject({ + success: false, + statusCode: 404, + }); + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('path'); + expect(res.body).toHaveProperty('requestId'); + }); + + it('should have consistent 401 shape', async () => { + const res = await request(app.getHttpServer()) + .get('/api/savings/my-subscriptions') + .expect(401); + + expect(res.body).toMatchObject({ + success: false, + statusCode: 401, + errorCode: 'UNAUTHORIZED', + }); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('requestId'); + }); + + it('should have consistent validation error shape with details array', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/register') + .send({ email: 'not-an-email', password: 'x' }) + .expect(400); + + expect(res.body).toMatchObject({ + success: false, + statusCode: 400, + errorCode: 'VALIDATION_ERROR', + }); + expect(res.body).toHaveProperty('details'); + expect(Array.isArray(res.body.details)).toBe(true); + expect(res.body.details.length).toBeGreaterThan(0); + expect(res.body.details[0]).toHaveProperty('field'); + expect(res.body.details[0]).toHaveProperty('message'); + }); + + it('should include requestId from correlation-id header', async () => { + const correlationId = 'test-correlation-12345'; + + const res = await request(app.getHttpServer()) + .get('/api/nonexistent-route') + .set('x-correlation-id', correlationId) + .expect(404); + + expect(res.body.requestId).toBe(correlationId); + }); + + it('should auto-generate requestId when not provided', async () => { + const res = await request(app.getHttpServer()) + .get('/api/nonexistent-route') + .expect(404); + + // Should be either a UUID or null depending on middleware ordering + expect(res.body).toHaveProperty('requestId'); + }); + + it('should have ISO-8601 timestamp', async () => { + const res = await request(app.getHttpServer()) + .get('/api/nonexistent-route') + .expect(404); + + const timestamp = new Date(res.body.timestamp); + expect(timestamp.getTime()).not.toBeNaN(); + expect(res.body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should not leak stack traces in error responses', async () => { + const res = await request(app.getHttpServer()) + .get('/api/nonexistent-route') + .expect(404); + + expect(res.body).not.toHaveProperty('stack'); + expect(JSON.stringify(res.body)).not.toContain('at '); + }); + + it('should have consistent shape for rate-limited requests', async () => { + // Trigger many requests rapidly — if throttled, check the 429 shape + const promises = Array.from({ length: 20 }, () => + request(app.getHttpServer()) + .post('/api/auth/login') + .send({ email: 'throttle@test.com', password: 'whatever' }), + ); + + const results = await Promise.all(promises); + const throttled = results.find((r) => r.status === 429); + + if (throttled) { + expect(throttled.body).toHaveProperty('success', false); + expect(throttled.body).toHaveProperty('errorCode', 'TOO_MANY_REQUESTS'); + expect(throttled.body).toHaveProperty('timestamp'); + } + }); +}); diff --git a/backend/test/governance-voting.e2e-spec.ts b/backend/test/governance-voting.e2e-spec.ts new file mode 100644 index 000000000..a40d76c9d --- /dev/null +++ b/backend/test/governance-voting.e2e-spec.ts @@ -0,0 +1,204 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/http-exception.filter'; +import { DataSource } from 'typeorm'; + +describe('Governance Voting E2E', () => { + let app: INestApplication; + let dataSource: DataSource; + let authToken: string; + + const testUser = { + email: `governance-e2e-${Date.now()}@test.com`, + password: 'Test@1234!Strong', + name: 'Governance E2E User', + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + dataSource = moduleFixture.get(DataSource); + + const registerRes = await request(app.getHttpServer()) + .post('/api/auth/register') + .send(testUser); + authToken = registerRes.body.access_token; + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + try { + await dataSource.query( + `DELETE FROM users WHERE email = $1`, + [testUser.email], + ); + } catch {} + } + await app?.close(); + }); + + describe('Proposal listing', () => { + it('should list proposals (public, no auth required)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/governance/proposals') + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should filter proposals by status', async () => { + const res = await request(app.getHttpServer()) + .get('/api/governance/proposals?status=Active') + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should reject invalid status filter', async () => { + const res = await request(app.getHttpServer()) + .get('/api/governance/proposals?status=INVALID_STATUS') + .expect(400); + + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('message'); + }); + }); + + describe('Proposal creation', () => { + it('should reject proposal without auth', async () => { + await request(app.getHttpServer()) + .post('/api/governance/proposals/create') + .send({ + description: 'Test proposal', + type: 'RATE_CHANGE', + action: { target: 'flexiRate', newValue: 10 }, + }) + .expect(401); + }); + + it('should reject proposal with missing required fields', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/create') + .set('Authorization', `Bearer ${authToken}`) + .send({}) + .expect(400); + + expect(res.body.errorCode).toBe('VALIDATION_ERROR'); + expect(res.body).toHaveProperty('details'); + expect(Array.isArray(res.body.details)).toBe(true); + }); + + it('should reject proposal with invalid type', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/create') + .set('Authorization', `Bearer ${authToken}`) + .send({ + description: 'Test proposal', + type: 'INVALID_TYPE', + action: { target: 'flexiRate', newValue: 10 }, + }) + .expect(400); + + expect(res.body.errorCode).toBe('VALIDATION_ERROR'); + }); + }); + + describe('Voting', () => { + it('should reject vote on non-existent proposal', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/999999/vote') + .set('Authorization', `Bearer ${authToken}`) + .send({ direction: 'for' }) + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should reject vote without auth', async () => { + await request(app.getHttpServer()) + .post('/api/governance/proposals/1/vote') + .send({ direction: 'for' }) + .expect(401); + }); + + it('should reject vote with missing direction', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/1/vote') + .set('Authorization', `Bearer ${authToken}`) + .send({}) + .expect(400); + + expect(res.body).toHaveProperty('errorCode'); + }); + }); + + describe('Proposal lifecycle endpoints', () => { + it('should reject queue on non-existent proposal', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/00000000-0000-0000-0000-000000000000/queue') + .set('Authorization', `Bearer ${authToken}`) + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should reject execute on non-existent proposal', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/00000000-0000-0000-0000-000000000000/execute') + .set('Authorization', `Bearer ${authToken}`) + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should get proposal status (404 for non-existent)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/governance/proposals/00000000-0000-0000-0000-000000000000/status') + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + }); + + describe('Delegation', () => { + it('should reject delegation without auth', async () => { + await request(app.getHttpServer()) + .post('/api/governance/delegate') + .send({ delegateToUserId: '00000000-0000-0000-0000-000000000000' }) + .expect(401); + }); + }); + + describe('Error response standardization', () => { + it('should include all required error fields', async () => { + const res = await request(app.getHttpServer()) + .post('/api/governance/proposals/create') + .set('Authorization', `Bearer ${authToken}`) + .send({}) + .expect(400); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('statusCode'); + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('path'); + expect(res.body).toHaveProperty('requestId'); + }); + }); +}); diff --git a/backend/test/jest-e2e-setup.ts b/backend/test/jest-e2e-setup.ts index dc32443ac..96a35edc0 100644 --- a/backend/test/jest-e2e-setup.ts +++ b/backend/test/jest-e2e-setup.ts @@ -39,6 +39,21 @@ process.env.MAIL_PORT = process.env.MAIL_PORT || '1025'; process.env.MAIL_USER = process.env.MAIL_USER || 'test_user'; process.env.MAIL_PASS = process.env.MAIL_PASS || 'test_password'; +// Database host fallback (in case DATABASE_URL is not set) +process.env.DB_HOST = process.env.DB_HOST || 'localhost'; +process.env.DB_PORT = process.env.DB_PORT || '5432'; +process.env.DB_NAME = process.env.DB_NAME || 'nestera_test'; +process.env.DB_USER = process.env.DB_USER || 'user'; +process.env.DB_PASS = process.env.DB_PASS || 'pass'; + +// Fallback RPC config +process.env.SOROBAN_RPC_FALLBACK_URLS = + process.env.SOROBAN_RPC_FALLBACK_URLS || 'https://soroban-testnet.stellar.org'; +process.env.HORIZON_FALLBACK_URLS = + process.env.HORIZON_FALLBACK_URLS || 'https://horizon-testnet.stellar.org'; +process.env.STELLAR_EVENT_POLL_INTERVAL = + process.env.STELLAR_EVENT_POLL_INTERVAL || '60000'; + // Redis configuration process.env.REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; diff --git a/backend/test/referrals-flow.e2e-spec.ts b/backend/test/referrals-flow.e2e-spec.ts new file mode 100644 index 000000000..c7c9abc4d --- /dev/null +++ b/backend/test/referrals-flow.e2e-spec.ts @@ -0,0 +1,173 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/http-exception.filter'; +import { DataSource } from 'typeorm'; + +describe('Referrals Flow E2E', () => { + let app: INestApplication; + let dataSource: DataSource; + let authToken: string; + + const testUser = { + email: `referrals-e2e-${Date.now()}@test.com`, + password: 'Test@1234!Strong', + name: 'Referrals E2E User', + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + dataSource = moduleFixture.get(DataSource); + + const registerRes = await request(app.getHttpServer()) + .post('/api/auth/register') + .send(testUser); + authToken = registerRes.body.access_token; + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + try { + await dataSource.query( + `DELETE FROM users WHERE email = $1`, + [testUser.email], + ); + } catch {} + } + await app?.close(); + }); + + describe('Referral code generation', () => { + it('should generate a referral code', async () => { + const res = await request(app.getHttpServer()) + .post('/api/referrals/generate') + .set('Authorization', `Bearer ${authToken}`) + .send({}); + + // May return 201 (success) or 400/404 (no campaign) depending on seed data + if (res.status === 201) { + expect(res.body).toHaveProperty('referralCode'); + expect(typeof res.body.referralCode).toBe('string'); + expect(res.body).toHaveProperty('id'); + } else { + expect(res.body).toHaveProperty('errorCode'); + } + }); + + it('should reject referral generation without auth', async () => { + const res = await request(app.getHttpServer()) + .post('/api/referrals/generate') + .send({}) + .expect(401); + + expect(res.body).toHaveProperty('errorCode'); + }); + }); + + describe('Referral stats', () => { + it('should return referral stats for authenticated user', async () => { + const res = await request(app.getHttpServer()) + .get('/api/referrals/stats') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body).toHaveProperty('totalReferrals'); + expect(res.body).toHaveProperty('completedReferrals'); + expect(res.body).toHaveProperty('totalRewards'); + }); + + it('should reject stats without auth', async () => { + await request(app.getHttpServer()) + .get('/api/referrals/stats') + .expect(401); + }); + }); + + describe('My referrals', () => { + it('should return empty referral list for new user', async () => { + const res = await request(app.getHttpServer()) + .get('/api/referrals/my-referrals') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + }); + + describe('Registration with referral code', () => { + it('should accept registration with a referral code', async () => { + const referred = { + email: `referred-e2e-${Date.now()}@test.com`, + password: 'Test@1234!Strong', + name: 'Referred User', + referralCode: 'NONEXISTENT_CODE', + }; + + const res = await request(app.getHttpServer()) + .post('/api/auth/register') + .send(referred); + + // Should not crash — either accepts or returns a structured error + expect([201, 400, 404]).toContain(res.status); + if (res.status !== 201) { + expect(res.body).toHaveProperty('errorCode'); + } + + // Cleanup + if (res.status === 201 && dataSource?.isInitialized) { + try { + await dataSource.query( + `DELETE FROM users WHERE email = $1`, + [referred.email], + ); + } catch {} + } + }); + }); + + describe('Check referral completion', () => { + it('should handle check-completion for non-existent user', async () => { + const res = await request(app.getHttpServer()) + .post('/api/referrals/check-completion') + .set('Authorization', `Bearer ${authToken}`) + .send({ + userId: '00000000-0000-0000-0000-000000000000', + depositAmount: '1000', + }); + + // Should not crash — returns 200 (no-op) or structured error + expect([200, 400, 404]).toContain(res.status); + }); + }); + + describe('Error format consistency', () => { + it('should return standardized errors on all referral endpoints', async () => { + const res = await request(app.getHttpServer()) + .get('/api/referrals/stats') + .expect(401); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('statusCode', 401); + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('path'); + expect(res.body).toHaveProperty('requestId'); + }); + }); +}); diff --git a/backend/test/savings-lifecycle.e2e-spec.ts b/backend/test/savings-lifecycle.e2e-spec.ts new file mode 100644 index 000000000..67d19e1f1 --- /dev/null +++ b/backend/test/savings-lifecycle.e2e-spec.ts @@ -0,0 +1,257 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/http-exception.filter'; +import { DataSource } from 'typeorm'; + +describe('Savings Lifecycle E2E', () => { + let app: INestApplication; + let dataSource: DataSource; + let authToken: string; + let userId: string; + + const testUser = { + email: `savings-e2e-${Date.now()}@test.com`, + password: 'Test@1234!Strong', + name: 'Savings E2E User', + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + dataSource = moduleFixture.get(DataSource); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + try { + await dataSource.query( + `DELETE FROM users WHERE email = $1`, + [testUser.email], + ); + } catch {} + } + await app?.close(); + }); + + describe('Authentication setup', () => { + it('should register a test user', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/register') + .send(testUser) + .expect(201); + + expect(res.body).toHaveProperty('access_token'); + authToken = res.body.access_token; + userId = res.body.user?.id ?? res.body.userId; + }); + + it('should login with the test user', async () => { + const res = await request(app.getHttpServer()) + .post('/api/auth/login') + .send({ email: testUser.email, password: testUser.password }) + .expect(200); + + expect(res.body).toHaveProperty('access_token'); + authToken = res.body.access_token; + }); + }); + + describe('Savings Products', () => { + it('should list available savings products', async () => { + const res = await request(app.getHttpServer()) + .get('/api/savings/products') + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should sort products by apy', async () => { + const res = await request(app.getHttpServer()) + .get('/api/savings/products?sort=apy') + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + }); + + describe('Subscription lifecycle', () => { + let productId: string; + let subscriptionId: string; + + it('should fail subscription with invalid product ID', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .send({ productId: '00000000-0000-0000-0000-000000000000', amount: 100 }) + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should fail subscription with zero amount', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .send({ productId: '00000000-0000-0000-0000-000000000000', amount: 0 }) + .expect(400); + + expect(res.body.errorCode).toBe('VALIDATION_ERROR'); + expect(res.body).toHaveProperty('details'); + }); + + it('should fail subscription without auth', async () => { + await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .send({ productId: '00000000-0000-0000-0000-000000000000', amount: 100 }) + .expect(401); + }); + + it('should reject withdrawal with invalid subscription', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/withdraw') + .set('Authorization', `Bearer ${authToken}`) + .send({ + subscriptionId: '00000000-0000-0000-0000-000000000000', + amount: 50, + }) + .expect(404); + + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should reject withdrawal with negative amount', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/withdraw') + .set('Authorization', `Bearer ${authToken}`) + .send({ + subscriptionId: '00000000-0000-0000-0000-000000000000', + amount: -10, + }) + .expect(400); + + expect(res.body.errorCode).toBe('VALIDATION_ERROR'); + }); + }); + + describe('Savings Goals', () => { + it('should reject goal creation without auth', async () => { + await request(app.getHttpServer()) + .post('/api/savings/goals') + .send({ goalName: 'Test Goal', targetAmount: 1000 }) + .expect(401); + }); + + it('should reject goal creation with invalid payload', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/goals') + .set('Authorization', `Bearer ${authToken}`) + .send({}) + .expect(400); + + expect(res.body.errorCode).toBe('VALIDATION_ERROR'); + expect(res.body).toHaveProperty('details'); + expect(res.body).toHaveProperty('requestId'); + expect(res.body).toHaveProperty('timestamp'); + }); + }); + + describe('Idempotency on savings endpoints', () => { + it('should handle duplicate subscribe requests with same idempotency key', async () => { + const idempotencyKey = `idem-subscribe-${Date.now()}`; + const payload = { + productId: '00000000-0000-0000-0000-000000000000', + amount: 100, + }; + + const res1 = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .set('Idempotency-Key', idempotencyKey) + .send(payload); + + const res2 = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .set('Idempotency-Key', idempotencyKey) + .send(payload); + + // Both responses should be identical (either both succeed or both fail with same error) + expect(res1.status).toBe(res2.status); + }); + + it('should return 409 for same idempotency key with different payload', async () => { + const idempotencyKey = `idem-conflict-${Date.now()}`; + + await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .set('Idempotency-Key', idempotencyKey) + .send({ productId: '00000000-0000-0000-0000-000000000000', amount: 100 }); + + const res2 = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .set('Idempotency-Key', idempotencyKey) + .send({ productId: '00000000-0000-0000-0000-000000000000', amount: 200 }); + + expect(res2.status).toBe(409); + expect(res2.body.errorCode).toBe('IDEMPOTENCY_CONFLICT'); + }); + }); + + describe('Error response format', () => { + it('should return standardized error shape for 401', async () => { + const res = await request(app.getHttpServer()) + .get('/api/savings/my-subscriptions') + .expect(401); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('statusCode', 401); + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('timestamp'); + expect(res.body).toHaveProperty('path'); + }); + + it('should return standardized error shape for validation errors', async () => { + const res = await request(app.getHttpServer()) + .post('/api/savings/subscribe') + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: 'not-a-number' }) + .expect(400); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('errorCode', 'VALIDATION_ERROR'); + expect(res.body).toHaveProperty('details'); + expect(Array.isArray(res.body.details)).toBe(true); + expect(res.body).toHaveProperty('requestId'); + expect(res.body).toHaveProperty('timestamp'); + }); + + it('should return standardized error shape for 404', async () => { + const res = await request(app.getHttpServer()) + .get('/api/savings/products/00000000-0000-0000-0000-000000000000') + .expect(404); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('statusCode', 404); + expect(res.body).toHaveProperty('errorCode'); + expect(res.body).toHaveProperty('timestamp'); + }); + }); +}); diff --git a/backend/test/transactions-flow.e2e-spec.ts b/backend/test/transactions-flow.e2e-spec.ts new file mode 100644 index 000000000..4fe650929 --- /dev/null +++ b/backend/test/transactions-flow.e2e-spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/http-exception.filter'; +import { DataSource } from 'typeorm'; + +describe('Transactions Flow E2E', () => { + let app: INestApplication; + let dataSource: DataSource; + let authToken: string; + + const testUser = { + email: `txn-e2e-${Date.now()}@test.com`, + password: 'Test@1234!Strong', + name: 'Transaction E2E User', + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + dataSource = moduleFixture.get(DataSource); + + const registerRes = await request(app.getHttpServer()) + .post('/api/auth/register') + .send(testUser); + authToken = registerRes.body.access_token; + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + try { + await dataSource.query( + `DELETE FROM users WHERE email = $1`, + [testUser.email], + ); + } catch {} + } + await app?.close(); + }); + + describe('Transaction history', () => { + it('should return empty transaction list for new user', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body).toHaveProperty('data'); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data).toHaveLength(0); + }); + + it('should reject unauthenticated transaction listing', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions') + .expect(401); + + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should support pagination parameters', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions?page=1&take=10') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('meta'); + }); + + it('should support filtering by type', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions?type=deposit') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body).toHaveProperty('data'); + }); + + it('should support date range filters', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions?startDate=2024-01-01&endDate=2026-12-31') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body).toHaveProperty('data'); + }); + }); + + describe('Transaction export', () => { + it('should export transactions as CSV', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions/export') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.headers['content-type']).toContain('text/csv'); + expect(res.headers['content-disposition']).toContain('nestera_history.csv'); + }); + + it('should reject unauthenticated export', async () => { + await request(app.getHttpServer()) + .get('/api/transactions/export') + .expect(401); + }); + }); + + describe('Transaction tagging', () => { + it('should reject tag on non-existent transaction', async () => { + const res = await request(app.getHttpServer()) + .post('/api/transactions/00000000-0000-0000-0000-000000000000/tag') + .set('Authorization', `Bearer ${authToken}`) + .send({ tag: 'groceries' }); + + expect([400, 404]).toContain(res.status); + expect(res.body).toHaveProperty('errorCode'); + }); + + it('should list categories', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions/categories') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + }); + }); + + describe('Error response consistency', () => { + it('should include requestId in error responses', async () => { + const res = await request(app.getHttpServer()) + .get('/api/transactions') + .expect(401); + + expect(res.body).toHaveProperty('requestId'); + expect(res.body).toHaveProperty('timestamp'); + expect(typeof res.body.timestamp).toBe('string'); + expect(new Date(res.body.timestamp).getTime()).not.toBeNaN(); + }); + }); +});