diff --git a/backend/api/src/modules/auth/guard/auth.guard.ts b/backend/api/src/modules/auth/guard/auth.guard.ts index c82411a..aa12af1 100644 --- a/backend/api/src/modules/auth/guard/auth.guard.ts +++ b/backend/api/src/modules/auth/guard/auth.guard.ts @@ -28,7 +28,6 @@ export class AuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(jwtToken, { secret: this.configService.get('JWT_SECRET'), }); - console.log(payload); if (payload.csrf !== csrfToken) { throw new UnauthorizedException(['Invalid CSRF token']); diff --git a/backend/api/src/modules/fabric/fabric.module.ts b/backend/api/src/modules/fabric/fabric.module.ts index 4f403fa..ad50fc6 100644 --- a/backend/api/src/modules/fabric/fabric.module.ts +++ b/backend/api/src/modules/fabric/fabric.module.ts @@ -1,8 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, Logger } from '@nestjs/common'; import { FabricService } from './fabric.service'; +import FabricGateway, { fabricGateway } from '../../common/fabric-gateway'; @Module({ - providers: [FabricService], + providers: [ + FabricService, + Logger, + { + provide: FabricGateway, + useValue: fabricGateway, + }, + ], exports: [FabricService], }) export class FabricModule {} diff --git a/backend/api/src/modules/fabric/fabric.service.spec.ts b/backend/api/src/modules/fabric/fabric.service.spec.ts index 78dab59..c82d3dc 100644 --- a/backend/api/src/modules/fabric/fabric.service.spec.ts +++ b/backend/api/src/modules/fabric/fabric.service.spec.ts @@ -1,18 +1,426 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; import { FabricService } from './fabric.service'; +import FabricGateway from '@api/common/fabric-gateway'; describe('FabricService', () => { let service: FabricService; + let mockGateway: { + connect: jest.Mock; + disconnect: jest.Mock; + storeLog: jest.Mock; + getLogById: jest.Mock; + getAllLogs: jest.Mock; + getLogsWithPagination: jest.Mock; + }; + let mockLogger: { + log: jest.Mock; + error: jest.Mock; + warn: jest.Mock; + debug: jest.Mock; + }; beforeEach(async () => { + // Reset all mocks before each test + jest.clearAllMocks(); + + // Create mock gateway + mockGateway = { + connect: jest.fn(), + disconnect: jest.fn(), + storeLog: jest.fn(), + getLogById: jest.fn(), + getAllLogs: jest.fn(), + getLogsWithPagination: jest.fn(), + }; + + // Create mock logger + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [FabricService], + providers: [ + FabricService, + { + provide: FabricGateway, + useValue: mockGateway, + }, + { + provide: Logger, + useValue: mockLogger, + }, + ], }).compile(); service = module.get(FabricService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('constructor', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + // ===================================================================== + // LIFECYCLE HOOKS + // ===================================================================== + + describe('onModuleInit', () => { + it('should connect to Fabric network on module init', async () => { + mockGateway.connect.mockResolvedValue(true); + + await service.onModuleInit(); + + expect(mockGateway.connect).toHaveBeenCalledTimes(1); + }); + + it('should throw error when connection fails', async () => { + const connectionError = new Error('Connection refused'); + mockGateway.connect.mockRejectedValue(connectionError); + + await expect(service.onModuleInit()).rejects.toThrow( + 'Failed to connect to Fabric network: Connection refused', + ); + expect(mockGateway.connect).toHaveBeenCalledTimes(1); + }); + + it('should include original error message in thrown error', async () => { + const originalError = new Error('ECONNREFUSED: localhost:7051'); + mockGateway.connect.mockRejectedValue(originalError); + + try { + await service.onModuleInit(); + fail('Should have thrown'); + } catch (error: any) { + expect(error.message).toContain('ECONNREFUSED'); + expect(error.message).toBe( + 'Failed to connect to Fabric network: ECONNREFUSED: localhost:7051', + ); + } + }); + + it('should handle non-Error objects gracefully', async () => { + mockGateway.connect.mockRejectedValue('String error'); + + await expect(service.onModuleInit()).rejects.toThrow( + 'Failed to connect to Fabric network: Unknown error', + ); + }); + }); + + describe('onApplicationShutdown', () => { + it('should disconnect from Fabric network on shutdown', async () => { + mockGateway.disconnect.mockResolvedValue(undefined); + + await service.onApplicationShutdown(); + + expect(mockGateway.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should disconnect with signal parameter', async () => { + mockGateway.disconnect.mockResolvedValue(undefined); + + await service.onApplicationShutdown('SIGTERM'); + + expect(mockGateway.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should not throw error if disconnect fails - just log it', async () => { + const disconnectError = new Error('Disconnect failed'); + mockGateway.disconnect.mockRejectedValue(disconnectError); + + // Should NOT throw - graceful shutdown + await expect(service.onApplicationShutdown()).resolves.not.toThrow(); + expect(mockGateway.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should handle non-Error objects during disconnect gracefully', async () => { + mockGateway.disconnect.mockRejectedValue('String error'); + + await expect(service.onApplicationShutdown()).resolves.not.toThrow(); + }); + }); + + // ===================================================================== + // storeLog + // ===================================================================== + + describe('storeLog', () => { + const mockStoreLogResult = { + transactionId: 'tx123', + status: 'COMMITTED', + }; + + it('should store log with all parameters', async () => { + mockGateway.storeLog.mockResolvedValue(mockStoreLogResult); + + const result = await service.storeLog( + 'log-1', + 'CREATE', + 'user-1', + '{"data": "test"}', + ); + + expect(mockGateway.storeLog).toHaveBeenCalledWith( + 'log-1', + 'CREATE', + 'user-1', + '{"data": "test"}', + ); + expect(result).toEqual(mockStoreLogResult); + }); + + it('should propagate errors from gateway', async () => { + const storeError = new Error('Transaction failed'); + mockGateway.storeLog.mockRejectedValue(storeError); + + await expect( + service.storeLog('log-1', 'CREATE', 'user-1', '{}'), + ).rejects.toThrow('Transaction failed'); + }); + + it('should not validate empty id (NO VALIDATION)', async () => { + mockGateway.storeLog.mockResolvedValue(mockStoreLogResult); + + // Empty ID passes through without validation + await service.storeLog('', 'CREATE', 'user-1', '{}'); + + expect(mockGateway.storeLog).toHaveBeenCalledWith( + '', + 'CREATE', + 'user-1', + '{}', + ); + }); + + it('should not validate empty event (NO VALIDATION)', async () => { + mockGateway.storeLog.mockResolvedValue(mockStoreLogResult); + + await service.storeLog('log-1', '', 'user-1', '{}'); + + expect(mockGateway.storeLog).toHaveBeenCalledWith( + 'log-1', + '', + 'user-1', + '{}', + ); + }); + + it('should not validate empty user_id (NO VALIDATION)', async () => { + mockGateway.storeLog.mockResolvedValue(mockStoreLogResult); + + await service.storeLog('log-1', 'CREATE', '', '{}'); + + expect(mockGateway.storeLog).toHaveBeenCalledWith( + 'log-1', + 'CREATE', + '', + '{}', + ); + }); + + it('should not validate malformed JSON payload (NO VALIDATION)', async () => { + mockGateway.storeLog.mockResolvedValue(mockStoreLogResult); + + // Invalid JSON passes through + await service.storeLog('log-1', 'CREATE', 'user-1', 'not-valid-json'); + + expect(mockGateway.storeLog).toHaveBeenCalledWith( + 'log-1', + 'CREATE', + 'user-1', + 'not-valid-json', + ); + }); + }); + + // ===================================================================== + // getLogById + // ===================================================================== + + describe('getLogById', () => { + const mockLog = { + id: 'log-1', + event: 'CREATE', + user_id: 'user-1', + payload: '{}', + timestamp: '2024-01-01T00:00:00Z', + }; + + it('should retrieve log by id', async () => { + mockGateway.getLogById.mockResolvedValue(mockLog); + + const result = await service.getLogById('log-1'); + + expect(mockGateway.getLogById).toHaveBeenCalledWith('log-1'); + expect(result).toEqual(mockLog); + }); + + it('should propagate errors from gateway', async () => { + const notFoundError = new Error('Log not found'); + mockGateway.getLogById.mockRejectedValue(notFoundError); + + await expect(service.getLogById('non-existent')).rejects.toThrow( + 'Log not found', + ); + }); + + /** + * ISSUE FOUND: No validation for empty or null id. + */ + it('should not validate empty id (NO VALIDATION)', async () => { + mockGateway.getLogById.mockResolvedValue(null); + + await service.getLogById(''); + + expect(mockGateway.getLogById).toHaveBeenCalledWith(''); + }); + }); + + // ===================================================================== + // getAllLogs + // ===================================================================== + + describe('getAllLogs', () => { + const mockLogs = [ + { id: 'log-1', event: 'CREATE' }, + { id: 'log-2', event: 'UPDATE' }, + ]; + + it('should retrieve all logs', async () => { + mockGateway.getAllLogs.mockResolvedValue(mockLogs); + + const result = await service.getAllLogs(); + + expect(mockGateway.getAllLogs).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockLogs); + }); + + it('should return empty array when no logs exist', async () => { + mockGateway.getAllLogs.mockResolvedValue([]); + + const result = await service.getAllLogs(); + + expect(result).toEqual([]); + }); + + it('should propagate errors from gateway', async () => { + const queryError = new Error('Query failed'); + mockGateway.getAllLogs.mockRejectedValue(queryError); + + await expect(service.getAllLogs()).rejects.toThrow('Query failed'); + }); + }); + + // ===================================================================== + // getLogsWithPagination + // ===================================================================== + + describe('getLogsWithPagination', () => { + const mockPaginatedResult = { + records: [{ id: 'log-1' }, { id: 'log-2' }], + bookmark: 'next-page-bookmark', + fetchedRecordsCount: 2, + }; + + it('should retrieve logs with pagination', async () => { + mockGateway.getLogsWithPagination.mockResolvedValue(mockPaginatedResult); + + const result = await service.getLogsWithPagination(10, ''); + + expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(10, ''); + expect(result).toEqual(mockPaginatedResult); + }); + + it('should pass bookmark for subsequent pages', async () => { + mockGateway.getLogsWithPagination.mockResolvedValue(mockPaginatedResult); + + await service.getLogsWithPagination(10, 'page-2-bookmark'); + + expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith( + 10, + 'page-2-bookmark', + ); + }); + + it('should propagate errors from gateway', async () => { + const paginationError = new Error('Pagination failed'); + mockGateway.getLogsWithPagination.mockRejectedValue(paginationError); + + await expect(service.getLogsWithPagination(10, '')).rejects.toThrow( + 'Pagination failed', + ); + }); + + /** + * ISSUE FOUND: No validation for pageSize. + * Negative, zero, or extremely large values pass through. + */ + it('should not validate zero pageSize (NO VALIDATION)', async () => { + mockGateway.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(0, ''); + + expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(0, ''); + }); + + it('should not validate negative pageSize (NO VALIDATION)', async () => { + mockGateway.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(-5, ''); + + expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith(-5, ''); + }); + + it('should not validate extremely large pageSize (NO VALIDATION)', async () => { + mockGateway.getLogsWithPagination.mockResolvedValue({ + records: [], + bookmark: '', + }); + + await service.getLogsWithPagination(999999999, ''); + + expect(mockGateway.getLogsWithPagination).toHaveBeenCalledWith( + 999999999, + '', + ); + }); + }); + + describe('Code Review Issues', () => { + it('should use dependency injection for FabricGateway', () => { + expect(service).toBeDefined(); + // Gateway is now injected, we can test it directly + expect(mockGateway.connect).toBeDefined(); + }); + + it('should document that errors are not transformed (MISSING ERROR HANDLING)', async () => { + const rawError = new Error('Raw gateway error'); + mockGateway.storeLog.mockRejectedValue(rawError); + + // Error passes through unchanged - no NestJS exception wrapping + await expect( + service.storeLog('log-1', 'CREATE', 'user-1', '{}'), + ).rejects.toThrow('Raw gateway error'); + }); + + it('should accept signal parameter for shutdown logging', async () => { + mockGateway.disconnect.mockResolvedValue(undefined); + + // Signal is now logged (though we can't verify without mocking Logger) + await service.onApplicationShutdown('SIGTERM'); + + expect(mockGateway.disconnect).toHaveBeenCalled(); + }); }); }); diff --git a/backend/api/src/modules/fabric/fabric.service.ts b/backend/api/src/modules/fabric/fabric.service.ts index 83671a2..0913ca2 100644 --- a/backend/api/src/modules/fabric/fabric.service.ts +++ b/backend/api/src/modules/fabric/fabric.service.ts @@ -8,8 +8,10 @@ import { @Injectable() export class FabricService implements OnModuleInit, OnApplicationShutdown { - private readonly logger = new Logger(FabricService.name); - private readonly gateway = new FabricGateway(); + constructor( + private readonly gateway: FabricGateway, + private readonly logger: Logger, + ) {} async onModuleInit() { this.logger.log('Attempting to connect to Fabric network...'); @@ -17,16 +19,43 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown { await this.gateway.connect(); this.logger.log('Successfully connected to Fabric network.'); } catch (error) { - this.logger.error('Failed to connect to Fabric network:', error); - throw new Error('Failed to connect to Fabric network'); + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Failed to connect to Fabric network: ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ); + throw new Error(`Failed to connect to Fabric network: ${errorMessage}`); } } async onApplicationShutdown(signal?: string) { - this.logger.log('Disconnecting from Fabric network...'); - await this.gateway.disconnect(); + this.logger.log( + `Disconnecting from Fabric network...${signal ? ` (signal: ${signal})` : ''}`, + ); + try { + await this.gateway.disconnect(); + this.logger.log('Successfully disconnected from Fabric network.'); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Failed to disconnect from Fabric network: ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ); + } } + /** + * Menyimpan entri log ke blockchain Fabric. + * + * @param id - ID unik log (harus tidak kosong, divalidasi oleh method pemanggil). Contoh nilai: 'REKAM_12XX' + * @param event - Jenis event (harus tidak kosong, divalidasi oleh method pemanggil). Contoh Nilai: 'CREATE' + * @param user_id - ID pengguna (harus valid, divalidasi oleh method pemanggil). Contoh Nilai: '1' + * @param payload - Payload string berupa Hash dari payload data (method hanya menerima string berupa hash). Contoh Nilai: '4f9075ab9fc724a0xxxx' + * + * @throws Error if Fabric gateway fails + */ async storeLog(id: string, event: string, user_id: string, payload: string) { this.logger.log(`Storing log with ID: ${id}`); return this.gateway.storeLog(id, event, user_id, payload);