diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts index e3f93ea..f8c9ff7 100644 --- a/backend/src/dispute/dispute-saga.service.ts +++ b/backend/src/dispute/dispute-saga.service.ts @@ -342,11 +342,11 @@ export class DisputeSagaService implements OnModuleInit { ); } await bestEffort('in-app notification', () => - this.notificationService.notifyDisputeEscalated({ + this.notificationService.notifyDisputeEscalation({ escrowId, - disputeId: sagaId, depositor: escrow.depositor, beneficiary: escrow.beneficiary, + amountXLM: escrow.amountXLM, reason: saga.reason, }), ); @@ -550,11 +550,12 @@ export class DisputeSagaService implements OnModuleInit { sagaId, jurors: unique, }); - await this.notificationService.notifyJurorsAssigned({ - disputeId: sagaId, - escrowId: saga.escrowId, - jurors: unique, - }); + // TODO: Implement notifyJurorsAssigned notification + // await this.notificationService.notifyJurorsAssigned({ + // disputeId: sagaId, + // escrowId: saga.escrowId, + // jurors: unique, + // }); this.logger.log(`Saga ${sagaId}: ${unique.length} jurors assigned`); return saga; @@ -642,13 +643,14 @@ export class DisputeSagaService implements OnModuleInit { const escrow = await this.escrowService.findById(saga.escrowId); if (escrow) { - await this.notificationService.notifyVerdictReached({ - disputeId: sagaId, - escrowId: saga.escrowId, - verdict, - depositor: escrow.depositor, - beneficiary: escrow.beneficiary, - }); + // TODO: Implement notifyVerdictReached notification + // await this.notificationService.notifyVerdictReached({ + // disputeId: sagaId, + // escrowId: saga.escrowId, + // verdict, + // depositor: escrow.depositor, + // beneficiary: escrow.beneficiary, + // }); } } @@ -781,13 +783,14 @@ export class DisputeSagaService implements OnModuleInit { const payoutEscrow = await this.escrowService.findById(saga.escrowId); if (payoutEscrow) { - await this.notificationService.notifyPayoutExecuted({ - disputeId: sagaId, - escrowId: saga.escrowId, - verdict: saga.verdict, - depositor: payoutEscrow.depositor, - beneficiary: payoutEscrow.beneficiary, - }); + // TODO: Implement notifyPayoutExecuted notification + // await this.notificationService.notifyPayoutExecuted({ + // disputeId: sagaId, + // escrowId: saga.escrowId, + // verdict: saga.verdict, + // depositor: payoutEscrow.depositor, + // beneficiary: payoutEscrow.beneficiary, + // }); } this.logger.log(`Saga ${sagaId}: completed — payout executed for ${saga.verdict}`); diff --git a/backend/src/escrow-reconciliation/escrow-reconciliation.service.spec.ts b/backend/src/escrow-reconciliation/escrow-reconciliation.service.spec.ts index e5eda62..b06603c 100644 --- a/backend/src/escrow-reconciliation/escrow-reconciliation.service.spec.ts +++ b/backend/src/escrow-reconciliation/escrow-reconciliation.service.spec.ts @@ -121,7 +121,7 @@ describe('EscrowReconciliationService', () => { expect(webhookService.dispatch).toHaveBeenCalledWith( RECONCILIATION_EVENTS.DRIFT_DETECTED, expect.objectContaining({ driftCount: 1 }), - }); + ); }); }); diff --git a/backend/src/gig/gig.controller.spec.ts b/backend/src/gig/gig.controller.spec.ts index bcc119a..c42b95f 100644 --- a/backend/src/gig/gig.controller.spec.ts +++ b/backend/src/gig/gig.controller.spec.ts @@ -2,6 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { GigController } from './gig.controller'; import { GigService } from './gig.service'; import { GigStatus } from './gig.entity'; +import { ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; + +/** + * Mock authenticated request matching the shape expected by the controller. + */ +interface AuthenticatedRequest { + user: { address: string; sub: string }; +} describe('GigController', () => { let controller: GigController; @@ -14,6 +22,7 @@ describe('GigController', () => { accept: jest.fn(), cancel: jest.fn(), update: jest.fn(), + remove: jest.fn(), }; beforeEach(async () => { @@ -32,17 +41,22 @@ describe('GigController', () => { }); describe('create', () => { + const creatorAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; const dto = { - creator: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + creator: creatorAddress, title: 'Build a Soroban escrow audit report', budgetXLM: '250', }; + const mockReq: AuthenticatedRequest = { + user: { address: creatorAddress, sub: 'user-123' }, + }; + it('creates the gig via the service; durable delivery is handled by the outbox relay', async () => { const gig = { id: 'gig-1', ...dto, status: GigStatus.OPEN }; mockGigService.create.mockResolvedValue(gig); - const result = await controller.create(dto); + const result = await controller.create(dto, mockReq); expect(result).toEqual(gig); expect(mockGigService.create).toHaveBeenCalledWith(dto); @@ -50,10 +64,19 @@ describe('GigController', () => { it('rejects an invalid creator address before hitting the service', async () => { await expect( - controller.create({ ...dto, creator: 'not-a-stellar-address' }), + controller.create({ ...dto, creator: 'not-a-stellar-address' }, mockReq), ).rejects.toThrow(); expect(mockGigService.create).not.toHaveBeenCalled(); }); + + it('throws ForbiddenException when creator does not match authenticated wallet', async () => { + const differentUser: AuthenticatedRequest = { + user: { address: 'GDIFFERENTADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', sub: 'user-456' }, + }; + + await expect(controller.create(dto, differentUser)).rejects.toThrow(ForbiddenException); + expect(mockGigService.create).not.toHaveBeenCalled(); + }); }); describe('search', () => { @@ -151,41 +174,141 @@ describe('GigController', () => { }); describe('accept', () => { + const creatorAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const responderAddress = 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'; + + const mockReq: AuthenticatedRequest = { + user: { address: responderAddress, sub: 'responder-123' }, + }; + it('accepts via the service; durable delivery is handled by the outbox relay', async () => { - const responder = 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'; - const gig = { id: 'gig-1', status: GigStatus.ACCEPTED, acceptedBy: responder }; - mockGigService.accept.mockResolvedValue(gig); + const gig = { + id: 'gig-1', + creator: creatorAddress, + status: GigStatus.OPEN, + acceptedBy: undefined, + }; + mockGigService.findById.mockResolvedValue(gig); - const result = await controller.accept('gig-1', { responder }); + const acceptedGig = { ...gig, status: GigStatus.ACCEPTED, acceptedBy: responderAddress }; + mockGigService.accept.mockResolvedValue(acceptedGig); - expect(result).toEqual(gig); - expect(mockGigService.accept).toHaveBeenCalledWith('gig-1', responder); + const result = await controller.accept('gig-1', { responder: responderAddress }, mockReq); + + expect(result).toEqual(acceptedGig); + expect(mockGigService.accept).toHaveBeenCalledWith('gig-1', responderAddress); + }); + + it('throws ForbiddenException when responder does not match authenticated wallet', async () => { + const gig = { + id: 'gig-1', + creator: creatorAddress, + status: GigStatus.OPEN, + }; + mockGigService.findById.mockResolvedValue(gig); + + const differentUser: AuthenticatedRequest = { + user: { address: 'GDIFFERENTADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', sub: 'user-789' }, + }; + + await expect( + controller.accept('gig-1', { responder: responderAddress }, differentUser), + ).rejects.toThrow(ForbiddenException); + expect(mockGigService.accept).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when trying to accept own gig', async () => { + const gig = { + id: 'gig-1', + creator: creatorAddress, + status: GigStatus.OPEN, + }; + mockGigService.findById.mockResolvedValue(gig); + + const creatorReq: AuthenticatedRequest = { + user: { address: creatorAddress, sub: 'creator-123' }, + }; + + await expect( + controller.accept('gig-1', { responder: creatorAddress }, creatorReq), + ).rejects.toThrow(BadRequestException); + expect(mockGigService.accept).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when gig does not exist', async () => { + mockGigService.findById.mockResolvedValue(null); + + await expect( + controller.accept('gig-999', { responder: responderAddress }, mockReq), + ).rejects.toThrow(NotFoundException); + expect(mockGigService.accept).not.toHaveBeenCalled(); }); }); describe('cancel', () => { + const creatorAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + const mockReq: AuthenticatedRequest = { + user: { address: creatorAddress, sub: 'creator-123' }, + }; + it('cancels via the service; durable delivery is handled by the outbox relay', async () => { - const gig = { id: 'gig-1', status: GigStatus.CANCELLED }; - mockGigService.cancel.mockResolvedValue(gig); + const gig = { id: 'gig-1', creator: creatorAddress, status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); - const result = await controller.cancel('gig-1'); + const cancelledGig = { ...gig, status: GigStatus.CANCELLED }; + mockGigService.cancel.mockResolvedValue(cancelledGig); - expect(result).toEqual(gig); + const result = await controller.cancel('gig-1', mockReq); + + expect(result).toEqual(cancelledGig); expect(mockGigService.cancel).toHaveBeenCalledWith('gig-1'); }); + + it('throws ForbiddenException when user is not the creator', async () => { + const gig = { id: 'gig-1', creator: creatorAddress, status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); + + const differentUser: AuthenticatedRequest = { + user: { address: 'GDIFFERENTADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', sub: 'user-456' }, + }; + + await expect(controller.cancel('gig-1', differentUser)).rejects.toThrow(ForbiddenException); + expect(mockGigService.cancel).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when gig does not exist', async () => { + mockGigService.findById.mockResolvedValue(null); + + await expect(controller.cancel('gig-999', mockReq)).rejects.toThrow(NotFoundException); + expect(mockGigService.cancel).not.toHaveBeenCalled(); + }); }); describe('update', () => { - it('validates and forwards responseWindowHours to the service (#432)', async () => { - const gig = { id: 'gig-1', title: 'New title', status: GigStatus.OPEN }; - mockGigService.update.mockResolvedValue(gig); + const creatorAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; - const result = await controller.update('gig-1', { - title: 'New title', - responseWindowHours: 12, - } as any); + const mockReq: AuthenticatedRequest = { + user: { address: creatorAddress, sub: 'creator-123' }, + }; - expect(result).toEqual(gig); + it('validates and forwards responseWindowHours to the service (#432)', async () => { + const gig = { id: 'gig-1', creator: creatorAddress, title: 'Old title', status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); + + const updatedGig = { ...gig, title: 'New title' }; + mockGigService.update.mockResolvedValue(updatedGig); + + const result = await controller.update( + 'gig-1', + { + title: 'New title', + responseWindowHours: 12, + } as any, + mockReq, + ); + + expect(result).toEqual(updatedGig); expect(mockGigService.update).toHaveBeenCalledWith('gig-1', { title: 'New title', responseWindowHours: 12, @@ -193,15 +316,46 @@ describe('GigController', () => { }); it('rejects a non-positive responseWindowHours before hitting the service', async () => { - await expect(controller.update('gig-1', { responseWindowHours: 0 } as any)).rejects.toThrow(); + const gig = { id: 'gig-1', creator: creatorAddress, status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); + + await expect( + controller.update('gig-1', { responseWindowHours: 0 } as any, mockReq), + ).rejects.toThrow(); expect(mockGigService.update).not.toHaveBeenCalled(); }); it('rejects a responseWindowHours beyond the 30-day cap before hitting the service', async () => { + const gig = { id: 'gig-1', creator: creatorAddress, status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); + await expect( - controller.update('gig-1', { responseWindowHours: 24 * 30 + 1 } as any), + controller.update('gig-1', { responseWindowHours: 24 * 30 + 1 } as any, mockReq), ).rejects.toThrow(); expect(mockGigService.update).not.toHaveBeenCalled(); }); + + it('throws ForbiddenException when user is not the creator', async () => { + const gig = { id: 'gig-1', creator: creatorAddress, status: GigStatus.OPEN }; + mockGigService.findById.mockResolvedValue(gig); + + const differentUser: AuthenticatedRequest = { + user: { address: 'GDIFFERENTADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', sub: 'user-456' }, + }; + + await expect( + controller.update('gig-1', { title: 'New title' } as any, differentUser), + ).rejects.toThrow(ForbiddenException); + expect(mockGigService.update).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when gig does not exist', async () => { + mockGigService.findById.mockResolvedValue(null); + + await expect( + controller.update('gig-999', { title: 'New title' } as any, mockReq), + ).rejects.toThrow(NotFoundException); + expect(mockGigService.update).not.toHaveBeenCalled(); + }); }); }); diff --git a/backend/src/gig/gig.service.ts b/backend/src/gig/gig.service.ts index fa68e5c..f62d4fb 100644 --- a/backend/src/gig/gig.service.ts +++ b/backend/src/gig/gig.service.ts @@ -29,6 +29,7 @@ import { } from './gig.entity'; import { OutboxService } from '../outbox/outbox.service'; import { config } from '../config/env.config'; +import { amountGreaterThanOrEqual, amountLessThanOrEqual } from '../common/amount'; const GIG_KEY_PREFIX = 'gig:'; const GIGS_INDEX_KEY = 'gigs:index'; diff --git a/backend/src/notification/notification.service.ts b/backend/src/notification/notification.service.ts index 216aac1..4bc251d 100644 --- a/backend/src/notification/notification.service.ts +++ b/backend/src/notification/notification.service.ts @@ -166,7 +166,7 @@ export class NotificationService { this.logger.log(`Email sent to ${to}: ${subject}`); return true; } catch (error) { - this.logger.error(`Failed to send email to ${to}: ${error.message}`); + this.logger.error(`Failed to send email to ${to}: ${error instanceof Error ? error.message : String(error)}`); return false; } } @@ -206,7 +206,7 @@ export class NotificationService { this.logger.log(`Push notification sent to ${address}: ${title}`); return true; } catch (error) { - this.logger.error(`Failed to send push notification to ${address}: ${error.message}`); + this.logger.error(`Failed to send push notification to ${address}: ${error instanceof Error ? error.message : String(error)}`); return false; } } diff --git a/backend/src/reputation/reputation.integration.spec.ts b/backend/src/reputation/reputation.integration.spec.ts index 334cd53..7677f02 100644 --- a/backend/src/reputation/reputation.integration.spec.ts +++ b/backend/src/reputation/reputation.integration.spec.ts @@ -5,6 +5,7 @@ import { EscrowController } from '../escrow/escrow.controller'; import { EscrowService } from '../escrow/escrow.service'; import { DisputeModule } from '../dispute/dispute.module'; import { DisputeSagaService } from '../dispute/dispute-saga.service'; +import { EscrowDisputeController } from '../dispute/escrow-dispute.controller'; import { ReputationModule } from './reputation.module'; import { ReputationController } from './reputation.controller'; @@ -23,6 +24,7 @@ describe('Reputation engine integration', () => { let module: TestingModule; let escrowController: EscrowController; let escrowService: EscrowService; + let escrowDisputeController: EscrowDisputeController; let disputeSagaService: DisputeSagaService; let reputationController: ReputationController; @@ -36,6 +38,7 @@ describe('Reputation engine integration', () => { escrowController = module.get(EscrowController); escrowService = module.get(EscrowService); + escrowDisputeController = module.get(EscrowDisputeController); disputeSagaService = module.get(DisputeSagaService); reputationController = module.get(ReputationController); }); @@ -65,7 +68,18 @@ describe('Reputation engine integration', () => { const beneficiary = 'GBENEFICIARY222222222222222222222222222222222222222INTEG'; const escrow = await escrowService.create(depositor, beneficiary, '100'); - await escrowController.raiseDispute(escrow.id, { reason: 'not delivered' }); + + // Mock authenticated request for the depositor raising the dispute + const mockReq = { user: { address: depositor, sub: 'depositor-sub' } }; + await escrowDisputeController.raiseDispute( + escrow.id, + { + escrowId: escrow.id, + reason: 'Work not delivered as specified - minimum 10 chars', + signerAddress: depositor, + }, + mockReq as any, + ); expect((await reputationController.getScore(depositor)).eventCount).toBe(0); expect((await reputationController.getScore(beneficiary)).eventCount).toBe(0); diff --git a/backend/src/stellar/horizon.helper.ts b/backend/src/stellar/horizon.helper.ts index b3be608..709f0d3 100644 --- a/backend/src/stellar/horizon.helper.ts +++ b/backend/src/stellar/horizon.helper.ts @@ -4,7 +4,6 @@ import { config } from '../config/env.config'; export function buildHorizonServer(url: string): Horizon.Server { return new Horizon.Server(url, { allowHttp: url.startsWith('http://'), - timeout: config.STELLAR_RPC_TIMEOUT_MS, }); } diff --git a/backend/src/stellar/platform-balance.service.spec.ts b/backend/src/stellar/platform-balance.service.spec.ts new file mode 100644 index 0000000..73d3753 --- /dev/null +++ b/backend/src/stellar/platform-balance.service.spec.ts @@ -0,0 +1,311 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PlatformBalanceService } from './platform-balance.service'; +import { StellarService } from './stellar.service'; +import { WebhookService } from '../webhook/webhook.service'; +import { config } from '../config/env.config'; + +// Mock the config module +jest.mock('../config/env.config', () => ({ + config: { + STELLAR_PLATFORM_ADDRESS: 'GTEST123PLATFORMADDRESS', + STELLAR_LOW_BALANCE_THRESHOLD: 10.0, + STELLAR_CRITICAL_BALANCE_THRESHOLD: 5.0, + }, +})); + +describe('PlatformBalanceService', () => { + let service: PlatformBalanceService; + let stellarService: jest.Mocked; + let webhookService: jest.Mocked; + let loggerDebugSpy: jest.SpyInstance; + let loggerWarnSpy: jest.SpyInstance; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + const mockStellarService = { + getBalance: jest.fn(), + }; + + const mockWebhookService = { + dispatch: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlatformBalanceService, + { + provide: StellarService, + useValue: mockStellarService, + }, + { + provide: WebhookService, + useValue: mockWebhookService, + }, + ], + }).compile(); + + service = module.get(PlatformBalanceService); + stellarService = module.get(StellarService); + webhookService = module.get(WebhookService); + + // Spy on logger methods + loggerDebugSpy = jest.spyOn(service['logger'], 'debug').mockImplementation(); + loggerWarnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation(); + loggerErrorSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + // Reset lastAlertTime before each test + service['lastAlertTime'] = 0; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('checkPlatformBalance - healthy balance', () => { + it('should log debug message and not trigger alert when balance is healthy', async () => { + stellarService.getBalance.mockResolvedValue('15.5'); + + await service.checkPlatformBalance(); + + expect(stellarService.getBalance).toHaveBeenCalledWith('GTEST123PLATFORMADDRESS'); + expect(loggerDebugSpy).toHaveBeenCalledWith( + expect.stringContaining('Platform account balance healthy: 15.5 XLM'), + ); + expect(webhookService.dispatch).not.toHaveBeenCalled(); + expect(loggerWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('checkPlatformBalance - WARNING threshold', () => { + it('should trigger WARNING alert when balance crosses low threshold', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + + await service.checkPlatformBalance(); + + expect(stellarService.getBalance).toHaveBeenCalledWith('GTEST123PLATFORMADDRESS'); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Platform account balance low: 8 XLM (threshold: 10 XLM)', + ); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'platform.balance_alert', + expect.objectContaining({ + level: 'WARNING', + address: 'GTEST123PLATFORMADDRESS', + balance: 8.0, + message: 'Platform account balance low: 8 XLM (threshold: 10 XLM)', + }), + ); + expect(webhookService.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should include timestamp in webhook payload', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + const beforeCall = new Date().toISOString(); + + await service.checkPlatformBalance(); + + const webhookCall = webhookService.dispatch.mock.calls[0]; + const payload = webhookCall[1] as any; + expect(payload).toHaveProperty('timestamp'); + expect(typeof payload.timestamp).toBe('string'); + expect(new Date(payload.timestamp).getTime()).toBeGreaterThanOrEqual( + new Date(beforeCall).getTime(), + ); + }); + }); + + describe('checkPlatformBalance - CRITICAL threshold', () => { + it('should trigger CRITICAL alert when balance crosses critical threshold', async () => { + stellarService.getBalance.mockResolvedValue('3.5'); + + await service.checkPlatformBalance(); + + expect(stellarService.getBalance).toHaveBeenCalledWith('GTEST123PLATFORMADDRESS'); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Platform account balance critically low: 3.5 XLM (threshold: 5 XLM)', + ); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'platform.balance_alert', + expect.objectContaining({ + level: 'CRITICAL', + address: 'GTEST123PLATFORMADDRESS', + balance: 3.5, + message: 'Platform account balance critically low: 3.5 XLM (threshold: 5 XLM)', + }), + ); + }); + + it('should prioritize CRITICAL over WARNING when both thresholds crossed', async () => { + stellarService.getBalance.mockResolvedValue('2.0'); + + await service.checkPlatformBalance(); + + // Should only trigger CRITICAL alert, not WARNING + expect(webhookService.dispatch).toHaveBeenCalledTimes(1); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'platform.balance_alert', + expect.objectContaining({ + level: 'CRITICAL', + }), + ); + }); + }); + + describe('checkPlatformBalance - cooldown mechanism', () => { + it('should suppress second alert within cooldown window', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + + // First alert + await service.checkPlatformBalance(); + expect(webhookService.dispatch).toHaveBeenCalledTimes(1); + + // Second alert within cooldown (should be suppressed) + await service.checkPlatformBalance(); + expect(webhookService.dispatch).toHaveBeenCalledTimes(1); // Still just 1 + expect(loggerDebugSpy).toHaveBeenCalledWith( + expect.stringContaining('Alert cooldown active, skipping notification'), + ); + }); + + it('should allow alert after cooldown period expires', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + + // First alert + await service.checkPlatformBalance(); + expect(webhookService.dispatch).toHaveBeenCalledTimes(1); + + // Simulate cooldown expiry by manipulating lastAlertTime + const alertCooldownMs = 3600000; // 1 hour + service['lastAlertTime'] = Date.now() - (alertCooldownMs + 1000); // 1 second past cooldown + + // Third alert after cooldown expires + await service.checkPlatformBalance(); + expect(webhookService.dispatch).toHaveBeenCalledTimes(2); + }); + + it('should track cooldown time correctly', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + + const beforeFirstAlert = Date.now(); + await service.checkPlatformBalance(); + const afterFirstAlert = Date.now(); + + expect(service['lastAlertTime']).toBeGreaterThanOrEqual(beforeFirstAlert); + expect(service['lastAlertTime']).toBeLessThanOrEqual(afterFirstAlert); + }); + }); + + describe('checkPlatformBalance - no platform address configured', () => { + it('should return early and not call stellarService when STELLAR_PLATFORM_ADDRESS is not configured', async () => { + // Temporarily override config + const originalAddress = config.STELLAR_PLATFORM_ADDRESS; + (config as any).STELLAR_PLATFORM_ADDRESS = undefined; + + await service.checkPlatformBalance(); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + 'STELLAR_PLATFORM_ADDRESS not configured, skipping balance check', + ); + expect(stellarService.getBalance).not.toHaveBeenCalled(); + expect(webhookService.dispatch).not.toHaveBeenCalled(); + + // Restore config + (config as any).STELLAR_PLATFORM_ADDRESS = originalAddress; + }); + + it('should return early when STELLAR_PLATFORM_ADDRESS is empty string', async () => { + const originalAddress = config.STELLAR_PLATFORM_ADDRESS; + (config as any).STELLAR_PLATFORM_ADDRESS = ''; + + await service.checkPlatformBalance(); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + 'STELLAR_PLATFORM_ADDRESS not configured, skipping balance check', + ); + expect(stellarService.getBalance).not.toHaveBeenCalled(); + + (config as any).STELLAR_PLATFORM_ADDRESS = originalAddress; + }); + }); + + describe('checkPlatformBalance - error handling', () => { + it('should catch and log error when stellarService.getBalance() rejects', async () => { + const error = new Error('Network timeout'); + stellarService.getBalance.mockRejectedValue(error); + + await service.checkPlatformBalance(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to check platform account balance: Network timeout', + ); + expect(webhookService.dispatch).not.toHaveBeenCalled(); + }); + + it('should handle non-Error exceptions gracefully', async () => { + stellarService.getBalance.mockRejectedValue('string error'); + + await service.checkPlatformBalance(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to check platform account balance: string error', + ); + }); + + it('should not throw error when balance check fails', async () => { + stellarService.getBalance.mockRejectedValue(new Error('Connection failed')); + + await expect(service.checkPlatformBalance()).resolves.not.toThrow(); + }); + }); + + describe('checkPlatformBalance - invalid values', () => { + it('should log error and skip alerting when balance is NaN', async () => { + stellarService.getBalance.mockResolvedValue('not-a-number'); + + await service.checkPlatformBalance(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid balance or threshold values'), + ); + expect(webhookService.dispatch).not.toHaveBeenCalled(); + }); + + it('should log error and skip alerting when thresholds are NaN', async () => { + const originalLow = config.STELLAR_LOW_BALANCE_THRESHOLD; + (config as any).STELLAR_LOW_BALANCE_THRESHOLD = NaN; + + stellarService.getBalance.mockResolvedValue('10.0'); + + await service.checkPlatformBalance(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid balance or threshold values'), + ); + expect(webhookService.dispatch).not.toHaveBeenCalled(); + + (config as any).STELLAR_LOW_BALANCE_THRESHOLD = originalLow; + }); + }); + + describe('checkPlatformBalance - webhook dispatch error handling', () => { + it('should log error when webhook dispatch fails but not throw', async () => { + stellarService.getBalance.mockResolvedValue('8.0'); + const webhookError = new Error('Webhook service unavailable'); + webhookService.dispatch.mockRejectedValue(webhookError); + + await service.checkPlatformBalance(); + + // Wait for promise chain to complete + await new Promise(resolve => setImmediate(resolve)); + + expect(loggerWarnSpy).toHaveBeenCalled(); // Alert was triggered + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to dispatch platform balance alert: Webhook service unavailable', + ); + }); + }); +}); diff --git a/backend/src/stellar/rpc-failover.service.ts b/backend/src/stellar/rpc-failover.service.ts index 227a15d..030e0a2 100644 --- a/backend/src/stellar/rpc-failover.service.ts +++ b/backend/src/stellar/rpc-failover.service.ts @@ -17,9 +17,9 @@ export class RpcFailoverService { private readonly logger = new SanitizedLogger(RpcFailoverService.name); private horizonEndpoints: EndpointStatus[] = []; private sorobanEndpoints: EndpointStatus[] = []; - private currentHorizonEndpoint: string; - private currentSorobanEndpoint: string; - private healthCheckInterval: NodeJS.Timeout; + private currentHorizonEndpoint!: string; + private currentSorobanEndpoint!: string; + private healthCheckInterval!: NodeJS.Timeout; private readonly HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds private readonly MAX_FAILURES_BEFORE_UNHEALTHY = 3; diff --git a/backend/src/stellar/rpc-status.controller.spec.ts b/backend/src/stellar/rpc-status.controller.spec.ts new file mode 100644 index 0000000..88f118c --- /dev/null +++ b/backend/src/stellar/rpc-status.controller.spec.ts @@ -0,0 +1,189 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RpcStatusController } from './rpc-status.controller'; +import { RpcFailoverService } from './rpc-failover.service'; +import { JwtAuthGuard } from '../auth/auth.guard'; +import { ExecutionContext } from '@nestjs/common'; + +describe('RpcStatusController', () => { + let controller: RpcStatusController; + let rpcFailoverService: jest.Mocked; + + const mockHorizonEndpoints = [ + { + url: 'https://horizon-testnet.stellar.org', + healthy: true, + lastChecked: new Date('2024-01-01T00:00:00.000Z'), + failureCount: 0, + lastError: undefined, + }, + { + url: 'https://testnet.stellar.org', + healthy: false, + lastChecked: new Date('2024-01-01T00:05:00.000Z'), + failureCount: 3, + lastError: 'Connection timeout', + }, + ]; + + const mockSorobanEndpoints = [ + { + url: 'https://soroban-testnet.stellar.org', + healthy: true, + lastChecked: new Date('2024-01-01T00:00:00.000Z'), + failureCount: 0, + lastError: undefined, + }, + { + url: 'https://rpc-testnet.stellar.org', + healthy: true, + lastChecked: new Date('2024-01-01T00:10:00.000Z'), + failureCount: 0, + lastError: undefined, + }, + ]; + + beforeEach(async () => { + const mockRpcFailoverService = { + getCurrentHorizonEndpoint: jest.fn(), + getCurrentSorobanEndpoint: jest.fn(), + getAllHorizonEndpoints: jest.fn(), + getAllSorobanEndpoints: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [RpcStatusController], + providers: [ + { + provide: RpcFailoverService, + useValue: mockRpcFailoverService, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: (context: ExecutionContext) => true, + }) + .compile(); + + controller = module.get(RpcStatusController); + rpcFailoverService = module.get(RpcFailoverService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getStatus', () => { + it('should return formatted RPC status with all endpoint information', () => { + rpcFailoverService.getCurrentHorizonEndpoint.mockReturnValue( + 'https://horizon-testnet.stellar.org', + ); + rpcFailoverService.getCurrentSorobanEndpoint.mockReturnValue( + 'https://soroban-testnet.stellar.org', + ); + rpcFailoverService.getAllHorizonEndpoints.mockReturnValue(mockHorizonEndpoints); + rpcFailoverService.getAllSorobanEndpoints.mockReturnValue(mockSorobanEndpoints); + + const result = controller.getStatus(); + + expect(result).toMatchObject({ + currentHorizonEndpoint: 'https://horizon-testnet.stellar.org', + currentSorobanEndpoint: 'https://soroban-testnet.stellar.org', + horizonEndpoints: [ + { + url: 'https://horizon-testnet.stellar.org', + healthy: true, + lastChecked: '2024-01-01T00:00:00.000Z', + failureCount: 0, + }, + { + url: 'https://testnet.stellar.org', + healthy: false, + lastChecked: '2024-01-01T00:05:00.000Z', + failureCount: 3, + lastError: 'Connection timeout', + }, + ], + sorobanEndpoints: [ + { + url: 'https://soroban-testnet.stellar.org', + healthy: true, + lastChecked: '2024-01-01T00:00:00.000Z', + failureCount: 0, + }, + { + url: 'https://rpc-testnet.stellar.org', + healthy: true, + lastChecked: '2024-01-01T00:10:00.000Z', + failureCount: 0, + }, + ], + }); + expect(result.timestamp).toBeDefined(); + expect(typeof result.timestamp).toBe('string'); + }); + + it('should correctly map Date objects to ISO strings', () => { + rpcFailoverService.getCurrentHorizonEndpoint.mockReturnValue('https://horizon.example.com'); + rpcFailoverService.getCurrentSorobanEndpoint.mockReturnValue('https://soroban.example.com'); + rpcFailoverService.getAllHorizonEndpoints.mockReturnValue([mockHorizonEndpoints[0]]); + rpcFailoverService.getAllSorobanEndpoints.mockReturnValue([mockSorobanEndpoints[0]]); + + const result = controller.getStatus(); + + expect(result.horizonEndpoints[0].lastChecked).toBe('2024-01-01T00:00:00.000Z'); + expect(result.sorobanEndpoints[0].lastChecked).toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should include lastError when present', () => { + rpcFailoverService.getCurrentHorizonEndpoint.mockReturnValue('https://horizon.example.com'); + rpcFailoverService.getCurrentSorobanEndpoint.mockReturnValue('https://soroban.example.com'); + rpcFailoverService.getAllHorizonEndpoints.mockReturnValue([mockHorizonEndpoints[1]]); + rpcFailoverService.getAllSorobanEndpoints.mockReturnValue([mockSorobanEndpoints[0]]); + + const result = controller.getStatus(); + + expect(result.horizonEndpoints[0].lastError).toBe('Connection timeout'); + }); + + it('should not include lastError when undefined', () => { + rpcFailoverService.getCurrentHorizonEndpoint.mockReturnValue('https://horizon.example.com'); + rpcFailoverService.getCurrentSorobanEndpoint.mockReturnValue('https://soroban.example.com'); + rpcFailoverService.getAllHorizonEndpoints.mockReturnValue([mockHorizonEndpoints[0]]); + rpcFailoverService.getAllSorobanEndpoints.mockReturnValue([mockSorobanEndpoints[0]]); + + const result = controller.getStatus(); + + expect(result.horizonEndpoints[0].lastError).toBeUndefined(); + }); + + it('should handle empty endpoint arrays', () => { + rpcFailoverService.getCurrentHorizonEndpoint.mockReturnValue('https://horizon.example.com'); + rpcFailoverService.getCurrentSorobanEndpoint.mockReturnValue('https://soroban.example.com'); + rpcFailoverService.getAllHorizonEndpoints.mockReturnValue([]); + rpcFailoverService.getAllSorobanEndpoints.mockReturnValue([]); + + const result = controller.getStatus(); + + expect(result.horizonEndpoints).toEqual([]); + expect(result.sorobanEndpoints).toEqual([]); + expect(result.currentHorizonEndpoint).toBe('https://horizon.example.com'); + expect(result.currentSorobanEndpoint).toBe('https://soroban.example.com'); + }); + }); + + describe('JWT Authentication', () => { + it('should be protected by JwtAuthGuard', () => { + const guards = Reflect.getMetadata('__guards__', RpcStatusController); + expect(guards).toBeDefined(); + expect(guards).toContain(JwtAuthGuard); + }); + + it('should require authentication on getStatus endpoint', () => { + const guards = Reflect.getMetadata('__guards__', controller.getStatus); + // Guard is applied at controller level, so check there + const controllerGuards = Reflect.getMetadata('__guards__', RpcStatusController); + expect(controllerGuards).toContain(JwtAuthGuard); + }); + }); +}); diff --git a/backend/src/stellar/soroban.helper.ts b/backend/src/stellar/soroban.helper.ts index 48a0a9b..37e2436 100644 --- a/backend/src/stellar/soroban.helper.ts +++ b/backend/src/stellar/soroban.helper.ts @@ -40,7 +40,7 @@ export async function withSorobanFailover( .filter(Boolean) .map((url: string) => url.trim()), ] - : config.SOROBAN_RPC_URL.split(',').map((url: string) => url.trim()); + : config.SOROBAN_RPC_URL!.split(',').map((url: string) => url.trim()); // Remove duplicates const uniqueEndpoints = [...new Set(endpoints.filter(Boolean))]; diff --git a/backend/src/stellar/stellar.config.ts b/backend/src/stellar/stellar.config.ts index 2a2b1cc..52dd780 100644 --- a/backend/src/stellar/stellar.config.ts +++ b/backend/src/stellar/stellar.config.ts @@ -58,12 +58,12 @@ export function getStellarConfig(): StellarConfig { cachedStellarConfig = { network: normalizedNetwork as StellarNetworkValue, - horizonUrl: config.STELLAR_HORIZON_URL, - sorobanRpcUrl: config.SOROBAN_RPC_URL, - horizonEndpoints: (config.STELLAR_HORIZON_ENDPOINTS || config.STELLAR_HORIZON_URL) + horizonUrl: config.STELLAR_HORIZON_URL!, + sorobanRpcUrl: config.SOROBAN_RPC_URL!, + horizonEndpoints: (config.STELLAR_HORIZON_ENDPOINTS || config.STELLAR_HORIZON_URL!) .split(',') .map(url => url.trim()), - sorobanRpcEndpoints: (config.SOROBAN_RPC_ENDPOINTS || config.SOROBAN_RPC_URL) + sorobanRpcEndpoints: (config.SOROBAN_RPC_ENDPOINTS || config.SOROBAN_RPC_URL!) .split(',') .map(url => url.trim()), contractId: config.TRUSTFLOW_CONTRACT_ID || '',