From f359786fb14f2c4bfc3eb41b2d25a5fb0f61203b Mon Sep 17 00:00:00 2001 From: yosaphatprs Date: Wed, 10 Dec 2025 15:16:12 +0700 Subject: [PATCH] tests: add unit test for proof module --- .../modules/proof/proof.controller.spec.ts | 155 +++++++ .../src/modules/proof/proof.service.spec.ts | 385 +++++++++++++++++- .../api/src/modules/proof/proof.service.ts | 6 +- 3 files changed, 544 insertions(+), 2 deletions(-) diff --git a/backend/api/src/modules/proof/proof.controller.spec.ts b/backend/api/src/modules/proof/proof.controller.spec.ts index fbd5847..d63c998 100644 --- a/backend/api/src/modules/proof/proof.controller.spec.ts +++ b/backend/api/src/modules/proof/proof.controller.spec.ts @@ -1,18 +1,173 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProofController } from './proof.controller'; +import { ProofService } from './proof.service'; +import { RequestProofDto } from './dto/request-proof.dto'; +import { LogProofDto } from './dto/log-proof.dto'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; describe('ProofController', () => { let controller: ProofController; + let proofService: jest.Mocked; + + const mockProofService = { + getProof: jest.fn(), + logVerificationProof: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProofController], + providers: [ + { + provide: ProofService, + useValue: mockProofService, + }, + ], }).compile(); controller = module.get(ProofController); + proofService = module.get(ProofService); + + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('requestProof', () => { + const validRequestProofDto: RequestProofDto = { + id_visit: 'VISIT_001', + }; + + const mockProofResponse = { + proof: { + pi_a: ['123', '456'], + pi_b: [ + ['789', '012'], + ['345', '678'], + ], + pi_c: ['901', '234'], + protocol: 'groth16', + curve: 'bn128', + }, + publicSignals: ['1', '18'], + }; + + it('should return proof successfully for valid id_visit', async () => { + mockProofService.getProof.mockResolvedValue(mockProofResponse); + + const result = await controller.requestProof(validRequestProofDto); + + expect(result).toEqual(mockProofResponse); + expect(proofService.getProof).toHaveBeenCalledWith(validRequestProofDto); + expect(proofService.getProof).toHaveBeenCalledTimes(1); + }); + + it('should throw NotFoundException when id_visit does not exist', async () => { + mockProofService.getProof.mockRejectedValue( + new NotFoundException('ID Visit tidak ditemukan'), + ); + + await expect( + controller.requestProof(validRequestProofDto), + ).rejects.toThrow(NotFoundException); + expect(proofService.getProof).toHaveBeenCalledWith(validRequestProofDto); + }); + + it('should throw BadRequestException when proof generation fails', async () => { + mockProofService.getProof.mockRejectedValue( + new BadRequestException( + "Can't generate proof from input based on constraint. Please check the input data and try again.", + ), + ); + + await expect( + controller.requestProof(validRequestProofDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should pass dto with empty id_visit to service (validation happens at pipe level)', async () => { + const emptyDto: RequestProofDto = { id_visit: '' }; + mockProofService.getProof.mockRejectedValue( + new NotFoundException('ID Visit tidak ditemukan'), + ); + + await expect(controller.requestProof(emptyDto)).rejects.toThrow( + NotFoundException, + ); + expect(proofService.getProof).toHaveBeenCalledWith(emptyDto); + }); + }); + + describe('logVerification', () => { + const validLogProofDto: LogProofDto = { + id_visit: 'VISIT_001', + proof: { pi_a: ['123'], pi_b: [['456']], pi_c: ['789'] }, + proofResult: true, + timestamp: '2025-12-10T10:00:00Z', + }; + + const mockLogResponse = { + response: { + txId: 'tx_123', + success: true, + }, + responseData: { + id: 'PROOF_VISIT_001', + event: 'proof_verification_logged', + user_id: '0', + payload: 'hashed_payload', + }, + }; + + it('should log verification proof successfully', async () => { + mockProofService.logVerificationProof.mockResolvedValue(mockLogResponse); + + const result = await controller.logVerification(validLogProofDto); + + expect(result).toEqual(mockLogResponse); + expect(proofService.logVerificationProof).toHaveBeenCalledWith( + validLogProofDto, + ); + expect(proofService.logVerificationProof).toHaveBeenCalledTimes(1); + }); + + it('should handle service errors gracefully', async () => { + mockProofService.logVerificationProof.mockRejectedValue( + new Error('Blockchain connection failed'), + ); + + await expect( + controller.logVerification(validLogProofDto), + ).rejects.toThrow('Blockchain connection failed'); + }); + + it('should pass dto with false proofResult to service', async () => { + const failedProofDto: LogProofDto = { + ...validLogProofDto, + proofResult: false, + }; + mockProofService.logVerificationProof.mockResolvedValue({ + ...mockLogResponse, + responseData: { ...mockLogResponse.responseData }, + }); + + await controller.logVerification(failedProofDto); + + expect(proofService.logVerificationProof).toHaveBeenCalledWith( + failedProofDto, + ); + }); + + // NOTE: This endpoint intentionally has no authentication + // as it is designed for external parties to log verification proofs + it('should accept request without authentication (intended for external parties)', async () => { + mockProofService.logVerificationProof.mockResolvedValue(mockLogResponse); + + const result = await controller.logVerification(validLogProofDto); + + expect(result).toBeDefined(); + }); + }); }); diff --git a/backend/api/src/modules/proof/proof.service.spec.ts b/backend/api/src/modules/proof/proof.service.spec.ts index 21d16bc..64f5413 100644 --- a/backend/api/src/modules/proof/proof.service.spec.ts +++ b/backend/api/src/modules/proof/proof.service.spec.ts @@ -1,18 +1,401 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProofService } from './proof.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RekammedisService } from '../rekammedis/rekammedis.service'; +import { LogService } from '../log/log.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { RequestProofDto } from './dto/request-proof.dto'; +import { LogProofDto } from './dto/log-proof.dto'; +import * as snarkjs from 'snarkjs'; + +// Mock snarkjs module +jest.mock('snarkjs', () => ({ + groth16: { + fullProve: jest.fn(), + prove: jest.fn(), + }, + wtns: { + calculate: jest.fn(), + }, +})); describe('ProofService', () => { let service: ProofService; + let prismaService: jest.Mocked; + let rekamMedisService: jest.Mocked; + let logService: jest.Mocked; + + const mockPrismaService = {}; + + const mockRekamMedisService = { + getAgeByIdVisit: jest.fn(), + }; + + const mockLogService = { + storeLog: jest.fn(), + getLogById: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ProofService], + providers: [ + ProofService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: RekammedisService, + useValue: mockRekamMedisService, + }, + { + provide: LogService, + useValue: mockLogService, + }, + ], }).compile(); service = module.get(ProofService); + prismaService = module.get(PrismaService); + rekamMedisService = module.get(RekammedisService); + logService = module.get(LogService); + + jest.clearAllMocks(); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getProof', () => { + const validRequestProofDto: RequestProofDto = { + id_visit: 'VISIT_001', + }; + + const mockProofResult = { + proof: { + pi_a: ['123', '456', '1'], + pi_b: [ + ['789', '012'], + ['345', '678'], + ['1', '0'], + ], + pi_c: ['901', '234', '1'], + protocol: 'groth16', + curve: 'bn128', + }, + publicSignals: ['1', '18'], + }; + + it('should generate proof successfully for adult patient (age >= 18)', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(25); + (snarkjs.groth16.fullProve as jest.Mock).mockResolvedValue( + mockProofResult, + ); + + const result = await service.getProof(validRequestProofDto); + + expect(result).toEqual({ + proof: mockProofResult.proof, + publicSignals: mockProofResult.publicSignals, + }); + expect(rekamMedisService.getAgeByIdVisit).toHaveBeenCalledWith( + 'VISIT_001', + ); + expect(snarkjs.groth16.fullProve).toHaveBeenCalledWith( + { age: 25, threshold: 18 }, + expect.any(String), + expect.any(String), + ); + }); + + it('should throw NotFoundException when id_visit does not exist', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(null); + + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + NotFoundException, + ); + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + 'ID Visit tidak ditemukan', + ); + }); + + it('should throw BadRequestException when proof generation fails (underage patient)', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(15); + (snarkjs.groth16.fullProve as jest.Mock).mockRejectedValue( + new Error('Constraint not satisfied'), + ); + + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + BadRequestException, + ); + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + "Can't generate proof from input based on constraint. Please check the input data and try again.", + ); + }); + + // Age 0 (newborn) should be valid and proceed to proof generation + it('should handle age 0 (newborn) correctly - proceeds to proof generation', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(0); + (snarkjs.groth16.fullProve as jest.Mock).mockRejectedValue( + new Error('Constraint not satisfied'), + ); + + // Now correctly treats 0 as valid age, fails at proof generation (age < 18) + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + BadRequestException, + ); + expect(snarkjs.groth16.fullProve).toHaveBeenCalledWith( + { age: 0, threshold: 18 }, + expect.any(String), + expect.any(String), + ); + }); + + // Negative age should throw clear error message + it('should throw BadRequestException for negative age with clear message', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(-5); + + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + BadRequestException, + ); + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + 'Age cannot be negative', + ); + // Should NOT reach groth16 + expect(snarkjs.groth16.fullProve).not.toHaveBeenCalled(); + }); + + it('should generate proof for edge case age exactly 18', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(18); + (snarkjs.groth16.fullProve as jest.Mock).mockResolvedValue( + mockProofResult, + ); + + const result = await service.getProof(validRequestProofDto); + + expect(result.proof).toBeDefined(); + expect(snarkjs.groth16.fullProve).toHaveBeenCalledWith( + { age: 18, threshold: 18 }, + expect.any(String), + expect.any(String), + ); + }); + + it('should throw BadRequestException for age exactly 17', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(17); + (snarkjs.groth16.fullProve as jest.Mock).mockRejectedValue( + new Error('Constraint: age >= threshold failed'), + ); + + await expect(service.getProof(validRequestProofDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should handle very large age values', async () => { + mockRekamMedisService.getAgeByIdVisit.mockResolvedValue(150); + (snarkjs.groth16.fullProve as jest.Mock).mockResolvedValue( + mockProofResult, + ); + + const result = await service.getProof(validRequestProofDto); + + expect(result.proof).toBeDefined(); + }); + }); + + describe('logVerificationProof', () => { + const validLogProofDto: LogProofDto = { + id_visit: 'VISIT_001', + proof: { pi_a: ['123'], pi_b: [['456']], pi_c: ['789'] }, + proofResult: true, + timestamp: '2025-12-10T10:00:00Z', + }; + + const mockLogResponse = { + txId: 'tx_abc123', + success: true, + }; + + it('should log verification proof successfully', async () => { + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(validLogProofDto); + + expect(result.response).toEqual(mockLogResponse); + expect(result.responseData).toBeDefined(); + expect(result.responseData.id).toBe('PROOF_VISIT_001'); + expect(result.responseData.event).toBe('proof_verification_logged'); + // BUG: responseData has 'External' but storeLog uses '0' - inconsistency! + expect(result.responseData.user_id).toBe('External'); + expect(logService.storeLog).toHaveBeenCalledTimes(1); + }); + + it('should create correct log ID format', async () => { + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(validLogProofDto); + + expect(result.responseData.id).toBe(`PROOF_${validLogProofDto.id_visit}`); + }); + + it('should hash the payload using sha256', async () => { + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + await service.logVerificationProof(validLogProofDto); + + const storeLogCall = mockLogService.storeLog.mock.calls[0][0]; + expect(storeLogCall.payload).toBeDefined(); + // SHA256 produces 64 character hex string + expect(storeLogCall.payload).toMatch(/^[a-f0-9]{64}$/); + }); + + it('should log failed verification (proofResult: false)', async () => { + const failedProofDto: LogProofDto = { + ...validLogProofDto, + proofResult: false, + }; + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(failedProofDto); + + expect(result.response).toEqual(mockLogResponse); + expect(logService.storeLog).toHaveBeenCalled(); + }); + + // BUG TEST: responseData.user_id is 'External' but storeLog sends '0' + it('should have INCONSISTENT user_id: responseData="External" but storeLog uses "0"', async () => { + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + await service.logVerificationProof(validLogProofDto); + + const storeLogCall = mockLogService.storeLog.mock.calls[0][0]; + // What's actually sent to blockchain + expect(storeLogCall.user_id).toBe('0'); + + // But responseData (returned to client) says 'External' - INCONSISTENCY! + const result = await service.logVerificationProof(validLogProofDto); + expect(result.responseData.user_id).toBe('External'); + }); + + it('should handle blockchain storage failure', async () => { + mockLogService.storeLog.mockRejectedValue( + new Error('Blockchain connection failed'), + ); + + await expect( + service.logVerificationProof(validLogProofDto), + ).rejects.toThrow('Blockchain connection failed'); + }); + + // BUG TEST: null values are used with fallback but still passed to hash + it('should handle null id_visit (uses fallback to null)', async () => { + const nullIdDto = { + ...validLogProofDto, + id_visit: null as unknown as string, + }; + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(nullIdDto); + + // Current behavior: creates ID as "PROOF_null" + expect(result.responseData.id).toBe('PROOF_null'); + }); + + // BUG TEST: undefined proof uses fallback but creates inconsistent hash + it('should handle undefined proof (uses fallback to null)', async () => { + const undefinedProofDto = { + ...validLogProofDto, + proof: undefined as unknown as object, + }; + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(undefinedProofDto); + + // Should still succeed but with null in payload + expect(result.response).toBeDefined(); + }); + + it('should return both response and responseData', async () => { + mockLogService.storeLog.mockResolvedValue(mockLogResponse); + + const result = await service.logVerificationProof(validLogProofDto); + + // INEFFICIENCY: Returns both response and responseData which contain similar info + expect(result).toHaveProperty('response'); + expect(result).toHaveProperty('responseData'); + }); + }); + + describe('calculateWitness', () => { + it('should calculate witness with correct inputs', async () => { + (snarkjs.wtns.calculate as jest.Mock).mockResolvedValue(undefined); + + await service.calculateWitness(25); + + expect(snarkjs.wtns.calculate).toHaveBeenCalledWith( + { age: 25, threshold: 18 }, + expect.any(String), + expect.any(String), + ); + }); + + it('should use hardcoded threshold of 18', async () => { + (snarkjs.wtns.calculate as jest.Mock).mockResolvedValue(undefined); + + await service.calculateWitness(30); + + const callArgs = (snarkjs.wtns.calculate as jest.Mock).mock.calls[0][0]; + expect(callArgs.threshold).toBe(18); + }); + + it('should throw error when witness calculation fails', async () => { + (snarkjs.wtns.calculate as jest.Mock).mockRejectedValue( + new Error('Invalid witness'), + ); + + await expect(service.calculateWitness(10)).rejects.toThrow( + 'Invalid witness', + ); + }); + }); + + describe('generateProof', () => { + const mockProofResult = { + proof: { pi_a: ['1'], pi_b: [['2']], pi_c: ['3'] }, + publicSignals: ['1', '18'], + }; + + it('should generate proof from witness file', async () => { + (snarkjs.groth16.prove as jest.Mock).mockResolvedValue(mockProofResult); + + const result = await service.generateProof(); + + expect(result).toEqual(mockProofResult); + expect(snarkjs.groth16.prove).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + ); + }); + + it('should throw error when proof generation fails', async () => { + (snarkjs.groth16.prove as jest.Mock).mockRejectedValue( + new Error('Proof generation failed'), + ); + + await expect(service.generateProof()).rejects.toThrow( + 'Proof generation failed', + ); + }); + }); + + describe('Path configurations', () => { + it('should have correct file path properties', () => { + expect(service.wasmPath).toContain('circuit.wasm'); + expect(service.zkeyPath).toContain('circuit_final.zkey'); + expect(service.vkeyPath).toContain('verification_key.json'); + expect(service.witnessPath).toContain('witness.wtns'); + }); + }); }); diff --git a/backend/api/src/modules/proof/proof.service.ts b/backend/api/src/modules/proof/proof.service.ts index c2a33e8..87f3517 100644 --- a/backend/api/src/modules/proof/proof.service.ts +++ b/backend/api/src/modules/proof/proof.service.ts @@ -47,10 +47,14 @@ export class ProofService { const age = await this.rekamMedisService.getAgeByIdVisit( requestProofDto.id_visit, ); - if (!age) { + if (age === null || age === undefined) { throw new NotFoundException('ID Visit tidak ditemukan'); } + if (age < 0) { + throw new BadRequestException('Age cannot be negative'); + } + // try { // await this.calculateWitness(age); // } catch (error) {