tests: add unit test for proof module

This commit is contained in:
yosaphatprs 2025-12-10 15:16:12 +07:00
parent 21f2990feb
commit f359786fb1
3 changed files with 544 additions and 2 deletions

View File

@ -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<ProofService>;
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>(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();
});
});
});

View File

@ -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<PrismaService>;
let rekamMedisService: jest.Mocked<RekammedisService>;
let logService: jest.Mocked<LogService>;
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>(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');
});
});
});

View File

@ -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) {