tests: add unit test for proof module
This commit is contained in:
parent
21f2990feb
commit
f359786fb1
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user