Compare commits

..

No commits in common. "7633bd25e38f314cd223d9020346b24a3c75abeb" and "7b0873e0da9a528ece051e1c9e8b682c262343d5" have entirely different histories.

19 changed files with 516 additions and 1600 deletions

View File

@ -1,15 +1,8 @@
import {
connect,
signers,
Gateway,
Network,
Contract,
} from '@hyperledger/fabric-gateway';
import { connect, signers } from '@hyperledger/fabric-gateway';
import * as grpc from '@grpc/grpc-js';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Logger } from '@nestjs/common';
const channelName = process.env.CHANNEL_NAME || 'mychannel';
const chaincodeName = process.env.CHAINCODE_NAME || 'logVerification';
@ -43,11 +36,10 @@ const peerEndpoint = process.env.PEER_ENDPOINT || 'localhost:7051';
const peerHostAlias = process.env.PEER_HOST_ALIAS || 'peer0.hospital.com';
class FabricGateway {
private readonly logger = new Logger(FabricGateway.name);
private gateway: Gateway | null = null;
private network: Network | null = null;
private contract: Contract | null = null;
private client: grpc.Client | null = null;
gateway: any;
network: any;
contract: any;
client: any;
constructor() {
this.gateway = null;
@ -72,7 +64,7 @@ class FabricGateway {
async ensureConnected() {
if (!this.contract) {
this.logger.warn('Not connected, attempting to reconnect...');
console.log('Not connected, attempting to reconnect...');
await this.connect();
}
}
@ -107,7 +99,7 @@ class FabricGateway {
async connect() {
try {
this.logger.log('Connecting to Hyperledger Fabric network...');
console.log('Connecting to Hyperledger Fabric network...');
this.client = await this.newGrpcConnection();
@ -135,10 +127,10 @@ class FabricGateway {
this.network = this.gateway.getNetwork(channelName);
this.contract = this.network.getContract(chaincodeName);
this.logger.log('Successfully connected to Fabric network');
console.log('Successfully connected to Fabric network');
return true;
} catch (error) {
this.logger.error('Failed to connect to Fabric network:', error);
console.error('Failed to connect to Fabric network:', error);
throw error;
}
}
@ -150,7 +142,7 @@ class FabricGateway {
if (this.client) {
this.client.close();
}
this.logger.log('Disconnected from Fabric network');
console.log('Disconnected from Fabric network');
}
async storeLog(
@ -165,9 +157,7 @@ class FabricGateway {
throw new Error('Not connected to network. Call connect() first.');
}
this.logger.debug(
`Submitting log storage transaction for log ID: ${id}...`,
);
console.log(`Submitting log storage transaction for log ID: ${id}...`);
const payloadString: string = payload;
const transaction = this.contract.newProposal('storeLog', {
arguments: [id, event, user_id, payloadString],
@ -184,15 +174,16 @@ class FabricGateway {
);
}
this.logger.log(
`Log stored successfully with transaction ID: ${transactionId}`,
console.log(
'Log stored successfully with transaction ID:',
transactionId,
);
return {
transactionId,
status: commitStatus.code.toString(),
status: commitStatus,
};
} catch (error) {
this.logger.error('Failed to store log:', error);
console.error('Failed to store log:', error);
throw error;
}
}
@ -203,9 +194,7 @@ class FabricGateway {
throw new Error('Not connected to network. Call connect() first.');
}
this.logger.debug(
`Evaluating getLogById transaction for log ID: ${id}...`,
);
console.log(`Evaluating getLogById transaction for log ID: ${id}...`);
const resultBytes = await this.contract.evaluateTransaction(
'getLogById',
@ -216,7 +205,7 @@ class FabricGateway {
return result;
} catch (error) {
this.logger.error('Failed to get log by ID:', error);
console.error('Failed to get log by ID:', error);
throw error;
}
}
@ -227,14 +216,14 @@ class FabricGateway {
throw new Error('Not connected to network. Call connect() first.');
}
this.logger.debug('Evaluating getAllLogs transaction...');
console.log('Evaluating getAllLogs transaction...');
const resultBytes = await this.contract.evaluateTransaction('getAllLogs');
const resultJson = new TextDecoder().decode(resultBytes);
const result = JSON.parse(resultJson);
return result;
} catch (error) {
this.logger.error('Failed to get all logs:', error);
console.error('Failed to get all logs:', error);
throw error;
}
}
@ -245,7 +234,7 @@ class FabricGateway {
throw new Error('Not connected to network. Call connect() first.');
}
this.logger.debug(
console.log(
`Evaluating getLogWithPagination transaction with pageSize: ${pageSize}, bookmark: ${bookmark}...`,
);
const resultBytes = await this.contract.evaluateTransaction(
@ -258,10 +247,12 @@ class FabricGateway {
const result = JSON.parse(resultJson);
return result;
} catch (error) {
this.logger.error('Failed to get logs with pagination:', error);
console.error('Failed to get logs with pagination:', error);
throw error;
}
}
}
export default FabricGateway;
export const fabricGateway = new FabricGateway();

View File

@ -98,11 +98,7 @@ describe('AuthController', () => {
it('should login user and set cookie in development mode', async () => {
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'development';
if (key === 'COOKIE_MAX_AGE') return '3600000';
return undefined;
});
mockConfigService.get.mockReturnValue('development');
const mockResponse = {
cookie: jest.fn(),
@ -128,11 +124,7 @@ describe('AuthController', () => {
it('should login user and set secure cookie in production mode', async () => {
mockAuthService.signIn.mockResolvedValue(mockSignInResponse);
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'NODE_ENV') return 'production';
if (key === 'COOKIE_MAX_AGE') return '3600000';
return undefined;
});
mockConfigService.get.mockReturnValue('production');
const mockResponse = {
cookie: jest.fn(),

View File

@ -150,99 +150,46 @@ describe('AuthService', () => {
});
});
/**
* Tests for isUserExisting check (BEFORE try block)
*/
it('should throw ConflictException when username already exists (via isUserExisting)', async () => {
// User already exists - isUserExisting returns true
mockPrisma.users.findUnique.mockResolvedValue({
id: BigInt(99),
username: 'testuser',
});
await expect(service.registerUser(createUserDto)).rejects.toThrow(
ConflictException,
);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
'Username ini sudah terdaftar',
it('should throw ConflictException when username already exists (P2002)', async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed',
{ code: 'P2002', clientVersion: '5.0.0' },
);
// Should NOT reach bcrypt.hash or users.create
expect(bcrypt.hash).not.toHaveBeenCalled();
expect(mockPrisma.users.create).not.toHaveBeenCalled();
});
it('should throw ConflictException when isUserExisting check fails (database error)', async () => {
// Database error during findUnique
mockPrisma.users.findUnique.mockRejectedValue(
new Error('Database connection failed'),
);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
ConflictException,
);
// Should NOT reach bcrypt.hash or users.create
expect(bcrypt.hash).not.toHaveBeenCalled();
expect(mockPrisma.users.create).not.toHaveBeenCalled();
});
it('should proceed to create user when isUserExisting returns false', async () => {
// User does not exist
mockPrisma.users.findUnique.mockResolvedValue(null);
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockResolvedValue(createdUser);
const result = await service.registerUser(createUserDto);
expect(mockPrisma.users.findUnique).toHaveBeenCalledWith({
where: { username: 'testuser' },
});
expect(bcrypt.hash).toHaveBeenCalled();
expect(mockPrisma.users.create).toHaveBeenCalled();
expect(result.username).toBe('testuser');
});
/**
* Tests for try/catch block errors (AFTER isUserExisting passes)
*/
it('should throw ConflictException when users.create fails', async () => {
// User does not exist (isUserExisting passes)
mockPrisma.users.findUnique.mockResolvedValue(null);
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
// But create fails
mockPrisma.users.create.mockRejectedValue(new Error('Create failed'));
mockPrisma.users.create.mockRejectedValue(prismaError);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
ConflictException,
);
});
/**
* BUG TEST: Error handling loses original error information
*
* The catch block throws generic ConflictException() without message,
* losing the original error context. This makes debugging harder.
*/
it('should preserve error context when create fails (current: loses context)', async () => {
mockPrisma.users.findUnique.mockResolvedValue(null);
it('should rethrow non-P2002 Prisma errors', async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError(
'Foreign key constraint failed',
{ code: 'P2003', clientVersion: '5.0.0' },
);
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockRejectedValue(new Error('Specific DB error'));
mockPrisma.users.create.mockRejectedValue(prismaError);
// Current behavior: throws generic ConflictException with no message
// Better behavior would be: InternalServerErrorException or include error context
try {
await service.registerUser(createUserDto);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ConflictException);
// The error message is empty/generic - this is a bug
// ConflictException() has default message "Conflict"
}
await expect(service.registerUser(createUserDto)).rejects.toThrow(
Prisma.PrismaClientKnownRequestError,
);
});
it('should rethrow unknown errors without wrapping', async () => {
const unknownError = new Error('Database connection failed');
mockConfigService.get.mockReturnValue(10);
(bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
mockPrisma.users.create.mockRejectedValue(unknownError);
await expect(service.registerUser(createUserDto)).rejects.toThrow(
'Database connection failed',
);
});
});

View File

@ -30,7 +30,7 @@ export class AuthService {
} catch (error) {
console.error('Error checking if user exists:', error);
user = null;
throw new ConflictException();
throw new InternalServerErrorException();
}
return !!user;
}
@ -63,7 +63,7 @@ export class AuthService {
};
} catch (error) {
console.error('Error registering user:', error);
throw new ConflictException();
throw new InternalServerErrorException();
}
}
@ -73,7 +73,7 @@ export class AuthService {
});
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
throw new UnauthorizedException('Username atau password salah');
throw new UnauthorizedException(['Username atau password salah']);
}
const csrfToken = crypto.randomBytes(32).toString('hex');

View File

@ -28,9 +28,10 @@ export class AuthGuard implements CanActivate {
const payload = await this.jwtService.verifyAsync(jwtToken, {
secret: this.configService.get<string>('JWT_SECRET'),
});
console.log(payload);
if (payload.csrf !== csrfToken) {
throw new UnauthorizedException('Invalid CSRF token');
throw new UnauthorizedException(['Invalid CSRF token']);
}
request['user'] = payload;

View File

@ -25,7 +25,7 @@ export class RolesGuard implements CanActivate {
const { user } = context.switchToHttp().getRequest();
if (!user?.role) {
throw new ForbiddenException('Insufficient permissions (no role)');
throw new ForbiddenException(['Insufficient permissions (no role)']);
}
const hasRole = requiredRoles.some((role) => user.role === role);
@ -34,6 +34,6 @@ export class RolesGuard implements CanActivate {
return true;
}
throw new ForbiddenException('You do not have the required role');
throw new ForbiddenException(['You do not have the required role']);
}
}

View File

@ -1,16 +1,8 @@
import { Module, Logger } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { FabricService } from './fabric.service';
import FabricGateway from '../../common/fabric-gateway';
@Module({
providers: [
FabricService,
Logger,
{
provide: FabricGateway,
useFactory: () => new FabricGateway(),
},
],
providers: [FabricService],
exports: [FabricService],
})
export class FabricModule {}

View File

@ -1,444 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InternalServerErrorException, 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,
{
provide: FabricGateway,
useValue: mockGateway,
},
{
provide: Logger,
useValue: mockLogger,
},
],
providers: [FabricService],
}).compile();
service = module.get<FabricService>(FabricService);
});
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 wrap gateway errors with InternalServerErrorException', async () => {
const storeError = new Error('Transaction failed');
mockGateway.storeLog.mockRejectedValue(storeError);
await expect(
service.storeLog('log-1', 'CREATE', 'user-1', '{}'),
).rejects.toThrow(InternalServerErrorException);
await expect(
service.storeLog('log-1', 'CREATE', 'user-1', '{}'),
).rejects.toThrow('Gagal menyimpan log ke blockchain');
});
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 wrap gateway errors with InternalServerErrorException', async () => {
const notFoundError = new Error('Log not found');
mockGateway.getLogById.mockRejectedValue(notFoundError);
await expect(service.getLogById('non-existent')).rejects.toThrow(
InternalServerErrorException,
);
await expect(service.getLogById('non-existent')).rejects.toThrow(
'Gagal mengambil log dari blockchain',
);
});
/**
* 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 wrap gateway errors with InternalServerErrorException', async () => {
const queryError = new Error('Query failed');
mockGateway.getAllLogs.mockRejectedValue(queryError);
await expect(service.getAllLogs()).rejects.toThrow(
InternalServerErrorException,
);
await expect(service.getAllLogs()).rejects.toThrow(
'Gagal mengambil semua log dari blockchain',
);
});
});
// =====================================================================
// 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 wrap gateway errors with InternalServerErrorException', async () => {
const paginationError = new Error('Pagination failed');
mockGateway.getLogsWithPagination.mockRejectedValue(paginationError);
await expect(service.getLogsWithPagination(10, '')).rejects.toThrow(
InternalServerErrorException,
);
await expect(service.getLogsWithPagination(10, '')).rejects.toThrow(
'Gagal mengambil log dengan paginasi dari blockchain',
);
});
/**
* 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 wrap errors with NestJS InternalServerErrorException', async () => {
const rawError = new Error('Raw gateway error');
mockGateway.storeLog.mockRejectedValue(rawError);
// Errors are now wrapped with InternalServerErrorException
await expect(
service.storeLog('log-1', 'CREATE', 'user-1', '{}'),
).rejects.toThrow(InternalServerErrorException);
});
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();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,7 +1,6 @@
import FabricGateway from '@api/common/fabric-gateway';
import {
Injectable,
InternalServerErrorException,
Logger,
OnApplicationShutdown,
OnModuleInit,
@ -9,10 +8,8 @@ import {
@Injectable()
export class FabricService implements OnModuleInit, OnApplicationShutdown {
constructor(
private readonly gateway: FabricGateway,
private readonly logger: Logger,
) {}
private readonly logger = new Logger(FabricService.name);
private readonly gateway = new FabricGateway();
async onModuleInit() {
this.logger.log('Attempting to connect to Fabric network...');
@ -20,94 +17,35 @@ export class FabricService implements OnModuleInit, OnApplicationShutdown {
await this.gateway.connect();
this.logger.log('Successfully connected to Fabric network.');
} catch (error) {
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}`);
this.logger.error('Failed to connect to Fabric network:', error);
throw new Error('Failed to connect to Fabric network');
}
}
async onApplicationShutdown(signal?: string) {
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,
);
}
this.logger.log('Disconnecting from Fabric network...');
await this.gateway.disconnect();
}
/**
* 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}`);
try {
return await this.gateway.storeLog(id, event, user_id, payload);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to store log: ${message}`);
throw new InternalServerErrorException(
'Gagal menyimpan log ke blockchain',
);
}
return this.gateway.storeLog(id, event, user_id, payload);
}
async getLogById(id: string) {
this.logger.log(`Retrieving log with ID: ${id}`);
try {
return await this.gateway.getLogById(id);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to get log by ID: ${message}`);
throw new InternalServerErrorException(
'Gagal mengambil log dari blockchain',
);
}
return this.gateway.getLogById(id);
}
async getAllLogs() {
this.logger.log('Retrieving all logs from Fabric network');
try {
return await this.gateway.getAllLogs();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to get all logs: ${message}`);
throw new InternalServerErrorException(
'Gagal mengambil semua log dari blockchain',
);
}
return this.gateway.getAllLogs();
}
async getLogsWithPagination(pageSize: number, bookmark: string) {
this.logger.log(
`Retrieving logs with pagination - Page Size: ${pageSize}, Bookmark: ${bookmark}`,
);
try {
return await this.gateway.getLogsWithPagination(pageSize, bookmark);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Failed to get logs with pagination: ${message}`);
throw new InternalServerErrorException(
'Gagal mengambil log dengan paginasi dari blockchain',
);
}
return this.gateway.getLogsWithPagination(pageSize, bookmark);
}
}

View File

@ -1,4 +1,11 @@
import { IsString, IsNotEmpty, Length, IsEnum } from 'class-validator';
import {
IsString,
IsNotEmpty,
Length,
IsJSON,
IsEnum,
IsNumber,
} from 'class-validator';
export class StoreLogDto {
@IsNotEmpty({ message: 'ID wajib diisi' })
@ -27,8 +34,7 @@ export class StoreLogDto {
event: string;
@IsNotEmpty({ message: 'User ID wajib diisi' })
@IsString({ message: 'User ID harus berupa string' })
user_id: string;
user_id: number | string;
@IsNotEmpty({ message: 'Payload wajib diisi' })
@IsString({ message: 'Payload harus berupa string' })

View File

@ -1,391 +0,0 @@
/**
* BackfillService - Database to Blockchain Migration
*
* STATUS: NOT IN USE (preserved for future use)
*
* This service syncs existing database records to the blockchain.
* It was designed for initial data migration and can be re-enabled
* when needed.
*
* To enable:
* 1. Add BackfillService to LogModule providers
* 2. Create a controller endpoint or CLI command to trigger it
* 3. Ensure BACKFILL_USER_ID is set in environment
*
* @see git log for original implementation history
*/
import { Injectable, Logger } from '@nestjs/common';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { sha256 } from '@api/common/crypto/hash';
import { PrismaService } from '../prisma/prisma.service';
import { FabricService } from '../fabric/fabric.service';
import type {
pemberian_obat as PemberianObat,
pemberian_tindakan as PemberianTindakan,
rekam_medis as RekamMedis,
} from '@dist/generated/prisma';
export interface BackfillFailure {
entity: EntityKey;
id: string;
reason: string;
timestamp: string;
}
interface BackfillState {
cursors: Partial<Record<EntityKey, string>>;
failures: Record<string, BackfillFailure>;
metadata?: Partial<
Record<
EntityKey,
{
lastRunAt: string;
processed: number;
success: number;
failed: number;
}
>
>;
}
export interface BackfillSummary {
processed: number;
success: number;
failed: number;
lastCursor: string | null;
failures: BackfillFailure[];
}
export type EntityKey = 'pemberian_obat' | 'rekam_medis' | 'pemberian_tindakan';
@Injectable()
export class BackfillService {
private readonly logger = new Logger(BackfillService.name);
private readonly statePath = path.resolve(
process.cwd(),
'backfill-state.json',
);
private readonly backfillUserId = process.env.BACKFILL_USER_ID ?? '9';
constructor(
private readonly fabricService: FabricService,
private readonly prisma: PrismaService,
) {}
async storeFromDBToBlockchain(
limitPerEntity = 5,
batchSize = 1,
): Promise<{
summaries: Record<string, BackfillSummary>;
checkpointFile: string;
}> {
const state = await this.loadState();
const summaries = {
pemberian_obat: await this.syncPemberianObat(
state,
limitPerEntity,
batchSize,
),
rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize),
pemberian_tindakan: await this.syncPemberianTindakan(
state,
limitPerEntity,
batchSize,
),
} as Record<EntityKey, BackfillSummary>;
const timestamp = new Date().toISOString();
await this.persistState({
...state,
metadata: {
...(state.metadata ?? {}),
pemberian_obat: {
lastRunAt: timestamp,
processed: summaries.pemberian_obat.processed,
success: summaries.pemberian_obat.success,
failed: summaries.pemberian_obat.failed,
},
rekam_medis: {
lastRunAt: timestamp,
processed: summaries.rekam_medis.processed,
success: summaries.rekam_medis.success,
failed: summaries.rekam_medis.failed,
},
pemberian_tindakan: {
lastRunAt: timestamp,
processed: summaries.pemberian_tindakan.processed,
success: summaries.pemberian_tindakan.success,
failed: summaries.pemberian_tindakan.failed,
},
},
});
return {
summaries,
checkpointFile: this.statePath,
};
}
private async syncPemberianObat(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianObat>(
state,
'pemberian_obat',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_obat.findMany(query);
},
async (record) => {
const payload = {
obat: record.obat,
jumlah_obat: record.jumlah_obat,
aturan_pakai: record.aturan_pakai,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`OBAT_${record.id}`,
'obat_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncRekamMedis(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<RekamMedis>(
state,
'rekam_medis',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id_visit: 'asc' },
take,
};
if (cursor) {
query.cursor = { id_visit: cursor };
query.skip = 1;
}
return this.prisma.rekam_medis.findMany(query);
},
async (record) => {
const payload = {
dokter_id: 123,
visit_id: record.id_visit,
anamnese: record.anamnese ?? '',
jenis_kasus: record.jenis_kasus ?? '',
tindak_lanjut: record.tindak_lanjut ?? '',
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`REKAM_${record.id_visit}`,
'rekam_medis_created',
this.backfillUserId,
payloadHash,
);
return record.id_visit;
},
(record) => record.id_visit,
);
}
private async syncPemberianTindakan(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianTindakan>(
state,
'pemberian_tindakan',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_tindakan.findMany(query);
},
async (record) => {
const payload = {
id_visit: record.id_visit,
tindakan: record.tindakan,
kategori_tindakan: record.kategori_tindakan ?? null,
kelompok_tindakan: record.kelompok_tindakan ?? null,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`TINDAKAN_${record.id}`,
'tindakan_dokter_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncEntity<T>(
state: BackfillState,
entity: EntityKey,
limit: number,
batchSize: number,
fetchBatch: (cursor: string | null, take: number) => Promise<T[]>,
processRecord: (record: T) => Promise<string>,
recordIdentifier: (record: T) => string,
): Promise<BackfillSummary> {
let cursor = state.cursors[entity] ?? null;
let processed = 0;
let success = 0;
let failed = 0;
while (processed < limit) {
const remaining = limit - processed;
if (remaining <= 0) {
break;
}
const take = Math.min(batchSize, remaining);
const records = await fetchBatch(cursor, take);
if (!records || records.length === 0) {
break;
}
const results = await Promise.allSettled(
records.map(async (record) => processRecord(record)),
);
results.forEach((result, index) => {
const id = recordIdentifier(records[index]);
const key = this.failureKey(entity, id);
if (result.status === 'fulfilled') {
success += 1;
delete state.failures[key];
} else {
failed += 1;
const failure = {
entity,
id,
reason: this.serializeError(result.reason),
timestamp: new Date().toISOString(),
} satisfies BackfillFailure;
state.failures[key] = failure;
this.logger.warn(
`Failed to backfill ${entity} ${id}: ${failure.reason}`,
);
}
});
processed += records.length;
cursor = recordIdentifier(records[records.length - 1]);
state.cursors[entity] = cursor;
await this.persistState(state);
if (records.length < take) {
break;
}
}
return {
processed,
success,
failed,
lastCursor: cursor,
failures: this.collectFailures(entity, state),
};
}
private async loadState(): Promise<BackfillState> {
try {
const raw = await fs.readFile(this.statePath, 'utf8');
const parsed = JSON.parse(raw);
return {
cursors: parsed.cursors ?? {},
failures: parsed.failures ?? {},
metadata: parsed.metadata ?? {},
} satisfies BackfillState;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return {
cursors: {},
failures: {},
metadata: {},
};
}
throw error;
}
}
private async persistState(state: BackfillState) {
const serializable = {
cursors: state.cursors,
failures: state.failures,
metadata: state.metadata ?? {},
};
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
await fs.writeFile(
this.statePath,
JSON.stringify(serializable, null, 2),
'utf8',
);
}
private collectFailures(
entity: EntityKey,
state: BackfillState,
): BackfillFailure[] {
return Object.values(state.failures).filter(
(entry) => entry.entity === entity,
);
}
private failureKey(entity: EntityKey, id: string) {
return `${entity}:${id}`;
}
private serializeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LogController } from './log.controller';
describe('LogController', () => {
let controller: LogController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LogController],
}).compile();
controller = module.get<LogController>(LogController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,29 @@
import { Controller, Post, UseGuards } from '@nestjs/common';
import { LogService } from './log.service';
import { AuthGuard } from '../auth/guard/auth.guard';
@Controller('log')
export class LogController {
constructor(private readonly logService: LogService) {}
@Post('/store-to-blockchain')
@UseGuards(AuthGuard)
async storeLog() {
return this.logService.storeFromDBToBlockchain();
}
// @Post()
// storeLog(@Body() dto: StoreLogDto) {
// return this.logService.storeLog(dto);
// }
// @Get(':id')
// getLogById(@Param('id') id: string) {
// return this.logService.getLogById(id);
// }
// @Get()
// getAllLogs() {
// return this.logService.getAllLogs();
// }
}

View File

@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { LogController } from './log.controller';
import { LogService } from './log.service';
import { FabricModule } from '../fabric/fabric.module';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [FabricModule],
imports: [FabricModule, PrismaModule],
controllers: [LogController],
providers: [LogService],
exports: [LogService],
})

View File

@ -2,587 +2,35 @@ import { Test, TestingModule } from '@nestjs/testing';
import { LogService } from './log.service';
import { FabricService } from '../fabric/fabric.service';
import { PrismaService } from '../prisma/prisma.service';
import { StoreLogDto } from './dto/store-log.dto';
describe('LogService', () => {
let service: LogService;
let mockFabricService: {
storeLog: jest.Mock;
getLogById: jest.Mock;
getLogsWithPagination: jest.Mock;
};
let mockPrismaService: {
pemberian_obat: { findMany: jest.Mock };
rekam_medis: { findMany: jest.Mock };
pemberian_tindakan: { findMany: jest.Mock };
};
beforeEach(async () => {
jest.clearAllMocks();
mockFabricService = {
const fabricServiceMock = {
storeLog: jest.fn(),
getLogById: jest.fn(),
getLogsWithPagination: jest.fn(),
};
} as unknown as FabricService;
mockPrismaService = {
const prismaServiceMock = {
pemberian_obat: { findMany: jest.fn() },
rekam_medis: { findMany: jest.fn() },
pemberian_tindakan: { findMany: jest.fn() },
};
} as unknown as PrismaService;
const module: TestingModule = await Test.createTestingModule({
providers: [
LogService,
{ provide: FabricService, useValue: mockFabricService },
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: FabricService, useValue: fabricServiceMock },
{ provide: PrismaService, useValue: prismaServiceMock },
],
}).compile();
service = module.get<LogService>(LogService);
});
describe('constructor', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
});
// =====================================================================
// storeLog
// =====================================================================
describe('storeLog', () => {
const validDto: StoreLogDto = {
id: 'REKAM_123',
event: 'rekam_medis_created',
user_id: '1',
payload: 'abc123hash',
};
it('should store log with valid DTO', async () => {
const mockResult = { transactionId: 'tx123', status: 'COMMITTED' };
mockFabricService.storeLog.mockResolvedValue(mockResult);
const result = await service.storeLog(validDto);
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
'REKAM_123',
'rekam_medis_created',
'1', // user_id converted to string
'abc123hash',
);
expect(result).toEqual(mockResult);
});
it('should convert numeric user_id to string', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, user_id: '42' });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'42', // number converted to string
expect.any(String),
);
});
it('should handle string user_id', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, user_id: 'user-abc' });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'user-abc',
expect.any(String),
);
});
it('should propagate errors from FabricService', async () => {
const error = new Error('Fabric transaction failed');
mockFabricService.storeLog.mockRejectedValue(error);
await expect(service.storeLog(validDto)).rejects.toThrow(
'Fabric transaction failed',
);
});
/**
* ISSUE: No validation in service layer.
* The DTO has class-validator decorators, but they only work
* with ValidationPipe in the controller. Direct service calls
* bypass validation.
*/
it('should not validate empty id (NO VALIDATION IN SERVICE)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, id: '' });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
'',
expect.any(String),
expect.any(String),
expect.any(String),
);
});
it('should not validate empty event (NO VALIDATION IN SERVICE)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, event: '' });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
'',
expect.any(String),
expect.any(String),
);
});
// =====================================================================
// Edge Cases: null/undefined inputs
// =====================================================================
describe('edge cases - null/undefined inputs', () => {
it('should throw when user_id is null (toString fails)', async () => {
await expect(
service.storeLog({ ...validDto, user_id: null as any }),
).rejects.toThrow();
expect(mockFabricService.storeLog).not.toHaveBeenCalled();
});
it('should throw when user_id is undefined (toString fails)', async () => {
await expect(
service.storeLog({ ...validDto, user_id: undefined as any }),
).rejects.toThrow();
expect(mockFabricService.storeLog).not.toHaveBeenCalled();
});
it('should pass null id to FabricService (NO VALIDATION)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, id: null as any });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
null,
expect.any(String),
expect.any(String),
expect.any(String),
);
});
it('should pass undefined id to FabricService (NO VALIDATION)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, id: undefined as any });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
undefined,
expect.any(String),
expect.any(String),
expect.any(String),
);
});
it('should pass null payload to FabricService (NO VALIDATION)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, payload: null as any });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(String),
null,
);
});
it('should handle user_id = 0 (falsy but valid)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, user_id: '0' });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'0',
expect.any(String),
);
});
it('should handle empty string user_id', async () => {
mockFabricService.storeLog.mockResolvedValue({});
await service.storeLog({ ...validDto, user_id: '' as any });
expect(mockFabricService.storeLog).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'',
expect.any(String),
);
});
});
});
// =====================================================================
// getLogById
// =====================================================================
describe('getLogById', () => {
const mockLog = {
id: 'REKAM_123',
event: 'rekam_medis_created',
user_id: '1',
payload: 'hash123',
timestamp: '2024-01-01T00:00:00Z',
};
it('should retrieve log by id', async () => {
mockFabricService.getLogById.mockResolvedValue(mockLog);
const result = await service.getLogById('REKAM_123');
expect(mockFabricService.getLogById).toHaveBeenCalledWith('REKAM_123');
expect(result).toEqual(mockLog);
});
it('should handle non-existent log', async () => {
mockFabricService.getLogById.mockResolvedValue(null);
const result = await service.getLogById('NON_EXISTENT');
expect(result).toBeNull();
});
it('should propagate errors from FabricService', async () => {
const error = new Error('Log not found');
mockFabricService.getLogById.mockRejectedValue(error);
await expect(service.getLogById('ERROR_ID')).rejects.toThrow(
'Log not found',
);
});
/**
* ISSUE: No validation for empty id parameter.
*/
it('should not validate empty id (NO VALIDATION)', async () => {
mockFabricService.getLogById.mockResolvedValue(null);
await service.getLogById('');
expect(mockFabricService.getLogById).toHaveBeenCalledWith('');
});
// =====================================================================
// Edge Cases: null/undefined inputs
// =====================================================================
describe('edge cases - null/undefined inputs', () => {
it('should pass null id to FabricService (NO VALIDATION)', async () => {
mockFabricService.getLogById.mockResolvedValue(null);
await service.getLogById(null as any);
expect(mockFabricService.getLogById).toHaveBeenCalledWith(null);
});
it('should pass undefined id to FabricService (NO VALIDATION)', async () => {
mockFabricService.getLogById.mockResolvedValue(null);
await service.getLogById(undefined as any);
expect(mockFabricService.getLogById).toHaveBeenCalledWith(undefined);
});
});
});
// =====================================================================
// 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 () => {
mockFabricService.getLogsWithPagination.mockResolvedValue(
mockPaginatedResult,
);
const result = await service.getLogsWithPagination(10, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
10,
'',
);
expect(result).toEqual(mockPaginatedResult);
});
it('should pass bookmark for subsequent pages', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue(
mockPaginatedResult,
);
await service.getLogsWithPagination(10, 'page-2-bookmark');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
10,
'page-2-bookmark',
);
});
it('should return empty result when no logs exist', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
fetchedRecordsCount: 0,
});
const result = await service.getLogsWithPagination(10, '');
expect(result.records).toEqual([]);
expect(result.fetchedRecordsCount).toBe(0);
});
it('should propagate errors from FabricService', async () => {
const error = new Error('Pagination failed');
mockFabricService.getLogsWithPagination.mockRejectedValue(error);
await expect(service.getLogsWithPagination(10, '')).rejects.toThrow(
'Pagination failed',
);
});
/**
* ISSUE: No validation for pageSize parameter.
* Zero, negative, or extremely large values pass through.
*/
it('should not validate zero pageSize (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(0, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
0,
'',
);
});
it('should not validate negative pageSize (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(-5, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
-5,
'',
);
});
// =====================================================================
// Edge Cases: null/undefined inputs
// =====================================================================
describe('edge cases - null/undefined inputs', () => {
it('should pass null pageSize to FabricService (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(null as any, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
null,
'',
);
});
it('should pass undefined pageSize to FabricService (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(undefined as any, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
undefined,
'',
);
});
it('should pass null bookmark to FabricService (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(10, null as any);
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
10,
null,
);
});
it('should handle NaN pageSize (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(NaN, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
NaN,
'',
);
});
it('should handle Infinity pageSize (NO VALIDATION)', async () => {
mockFabricService.getLogsWithPagination.mockResolvedValue({
records: [],
bookmark: '',
});
await service.getLogsWithPagination(Infinity, '');
expect(mockFabricService.getLogsWithPagination).toHaveBeenCalledWith(
Infinity,
'',
);
});
});
});
// =====================================================================
// CODE REVIEW FINDINGS
// =====================================================================
describe('Code Review Issues', () => {
/**
* CRITICAL ISSUE 1: storeFromDBToBlockchain method is commented out!
*
* The controller calls this.logService.storeFromDBToBlockchain()
* but the entire implementation is commented out in the service.
* This will cause a runtime error when the endpoint is called.
*/
it('should document that storeFromDBToBlockchain is commented out (BROKEN ENDPOINT)', () => {
// Check if the method exists on the service
expect(typeof (service as any).storeFromDBToBlockchain).toBe('undefined');
});
/**
* ISSUE 2: Hardcoded backfillUserId.
*
* The service uses process.env.BACKFILL_USER_ID ?? '9' which defaults to '9'.
* This could cause audit issues if the default is used unintentionally.
*/
it('should have backfillUserId property', () => {
// This is a private property, testing its existence indirectly
expect(service).toBeDefined();
});
/**
* ISSUE 3: statePath uses process.cwd() which can vary.
*
* In different environments (dev, test, prod), process.cwd()
* may return different paths, causing file access issues.
*/
/**
* ISSUE 4: No error handling in storeLog method.
*
* Errors from FabricService propagate unchanged.
* Should wrap with appropriate NestJS exceptions.
*/
it('should not transform errors (MISSING ERROR HANDLING)', async () => {
const rawError = new Error('Raw fabric error');
mockFabricService.storeLog.mockRejectedValue(rawError);
await expect(
service.storeLog({
id: 'test',
event: 'rekam_medis_created',
user_id: '1',
payload: 'hash',
}),
).rejects.toThrow('Raw fabric error');
});
/**
* ISSUE 5: Commented out code is ~300 lines.
*
* The storeFromDBToBlockchain and related methods are all commented.
* This should either be:
* - Removed if not needed
* - Uncommented and tested if needed
* - Moved to a separate branch/PR
*/
/**
* ISSUE 6: The DTO allows both number and string for user_id.
*
* StoreLogDto has: user_id: number | string;
*
* This is inconsistent - the service converts it to string anyway.
* Should pick one type and stick with it.
*/
it('should handle both number and string user_id (TYPE INCONSISTENCY)', async () => {
mockFabricService.storeLog.mockResolvedValue({});
// Number
await service.storeLog({
id: 'test1',
event: 'rekam_medis_created',
user_id: '123',
payload: 'hash',
});
expect(mockFabricService.storeLog).toHaveBeenLastCalledWith(
'test1',
'rekam_medis_created',
'123',
'hash',
);
// String
await service.storeLog({
id: 'test2',
event: 'rekam_medis_created',
user_id: 'abc',
payload: 'hash',
});
expect(mockFabricService.storeLog).toHaveBeenLastCalledWith(
'test2',
'rekam_medis_created',
'abc',
'hash',
);
});
/**
* ISSUE 7: No logging in active methods.
*
* The service has a Logger but storeLog, getLogById, and
* getLogsWithPagination don't use it.
*/
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,10 +1,62 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { sha256 } from '@api/common/crypto/hash';
import { PrismaService } from '../prisma/prisma.service';
import { FabricService } from '../fabric/fabric.service';
import { StoreLogDto } from './dto/store-log.dto';
import type {
pemberian_obat as PemberianObat,
pemberian_tindakan as PemberianTindakan,
rekam_medis as RekamMedis,
} from '@dist/generated/prisma';
export interface BackfillFailure {
entity: EntityKey;
id: string;
reason: string;
timestamp: string;
}
interface BackfillState {
cursors: Partial<Record<EntityKey, string>>;
failures: Record<string, BackfillFailure>;
metadata?: Partial<
Record<
EntityKey,
{
lastRunAt: string;
processed: number;
success: number;
failed: number;
}
>
>;
}
export interface BackfillSummary {
processed: number;
success: number;
failed: number;
lastCursor: string | null;
failures: BackfillFailure[];
}
export type EntityKey = 'pemberian_obat' | 'rekam_medis' | 'pemberian_tindakan';
@Injectable()
export class LogService {
constructor(private readonly fabricService: FabricService) {}
private readonly logger = new Logger(LogService.name);
private readonly statePath = path.resolve(
process.cwd(),
'backfill-state.json',
);
private readonly backfillUserId = process.env.BACKFILL_USER_ID ?? '9';
constructor(
private readonly fabricService: FabricService,
private readonly prisma: PrismaService,
) {}
async storeLog(dto: StoreLogDto) {
const { id, event, user_id, payload } = dto;
@ -18,4 +70,320 @@ export class LogService {
async getLogsWithPagination(pageSize: number, bookmark: string) {
return this.fabricService.getLogsWithPagination(pageSize, bookmark);
}
// async storeFromDBToBlockchain() {}
async storeFromDBToBlockchain(
limitPerEntity = 5,
batchSize = 1,
): Promise<{
summaries: Record<string, BackfillSummary>;
checkpointFile: string;
}> {
const state = await this.loadState();
const summaries = {
pemberian_obat: await this.syncPemberianObat(
state,
limitPerEntity,
batchSize,
),
rekam_medis: await this.syncRekamMedis(state, limitPerEntity, batchSize),
pemberian_tindakan: await this.syncPemberianTindakan(
state,
limitPerEntity,
batchSize,
),
} as Record<EntityKey, BackfillSummary>;
const timestamp = new Date().toISOString();
await this.persistState({
...state,
metadata: {
...(state.metadata ?? {}),
pemberian_obat: {
lastRunAt: timestamp,
processed: summaries.pemberian_obat.processed,
success: summaries.pemberian_obat.success,
failed: summaries.pemberian_obat.failed,
},
rekam_medis: {
lastRunAt: timestamp,
processed: summaries.rekam_medis.processed,
success: summaries.rekam_medis.success,
failed: summaries.rekam_medis.failed,
},
pemberian_tindakan: {
lastRunAt: timestamp,
processed: summaries.pemberian_tindakan.processed,
success: summaries.pemberian_tindakan.success,
failed: summaries.pemberian_tindakan.failed,
},
},
});
return {
summaries,
checkpointFile: this.statePath,
};
}
private async syncPemberianObat(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianObat>(
state,
'pemberian_obat',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_obat.findMany(query);
},
async (record) => {
const payload = {
obat: record.obat,
jumlah_obat: record.jumlah_obat,
aturan_pakai: record.aturan_pakai,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`OBAT_${record.id}`,
'obat_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncRekamMedis(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<RekamMedis>(
state,
'rekam_medis',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id_visit: 'asc' },
take,
};
if (cursor) {
query.cursor = { id_visit: cursor };
query.skip = 1;
}
return this.prisma.rekam_medis.findMany(query);
},
async (record) => {
const payload = {
dokter_id: 123,
visit_id: record.id_visit,
anamnese: record.anamnese ?? '',
jenis_kasus: record.jenis_kasus ?? '',
tindak_lanjut: record.tindak_lanjut ?? '',
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`REKAM_${record.id_visit}`,
'rekam_medis_created',
this.backfillUserId,
payloadHash,
);
return record.id_visit;
},
(record) => record.id_visit,
);
}
private async syncPemberianTindakan(
state: BackfillState,
limit: number,
batchSize: number,
): Promise<BackfillSummary> {
return this.syncEntity<PemberianTindakan>(
state,
'pemberian_tindakan',
limit,
batchSize,
async (cursor, take) => {
const query: any = {
orderBy: { id: 'asc' },
take,
};
if (cursor) {
query.cursor = { id: Number(cursor) };
query.skip = 1;
}
return this.prisma.pemberian_tindakan.findMany(query);
},
async (record) => {
const payload = {
id_visit: record.id_visit,
tindakan: record.tindakan,
kategori_tindakan: record.kategori_tindakan ?? null,
kelompok_tindakan: record.kelompok_tindakan ?? null,
};
const payloadHash = sha256(JSON.stringify(payload));
await this.fabricService.storeLog(
`TINDAKAN_${record.id}`,
'tindakan_dokter_created',
this.backfillUserId,
payloadHash,
);
return `${record.id}`;
},
(record) => `${record.id}`,
);
}
private async syncEntity<T>(
state: BackfillState,
entity: EntityKey,
limit: number,
batchSize: number,
fetchBatch: (cursor: string | null, take: number) => Promise<T[]>,
processRecord: (record: T) => Promise<string>,
recordIdentifier: (record: T) => string,
): Promise<BackfillSummary> {
let cursor = state.cursors[entity] ?? null;
let processed = 0;
let success = 0;
let failed = 0;
while (processed < limit) {
const remaining = limit - processed;
if (remaining <= 0) {
break;
}
const take = Math.min(batchSize, remaining);
const records = await fetchBatch(cursor, take);
if (!records || records.length === 0) {
break;
}
const results = await Promise.allSettled(
records.map(async (record) => processRecord(record)),
);
results.forEach((result, index) => {
const id = recordIdentifier(records[index]);
const key = this.failureKey(entity, id);
if (result.status === 'fulfilled') {
success += 1;
delete state.failures[key];
} else {
failed += 1;
const failure = {
entity,
id,
reason: this.serializeError(result.reason),
timestamp: new Date().toISOString(),
} satisfies BackfillFailure;
state.failures[key] = failure;
this.logger.warn(
`Failed to backfill ${entity} ${id}: ${failure.reason}`,
);
}
});
processed += records.length;
cursor = recordIdentifier(records[records.length - 1]);
state.cursors[entity] = cursor;
await this.persistState(state);
if (records.length < take) {
break;
}
}
return {
processed,
success,
failed,
lastCursor: cursor,
failures: this.collectFailures(entity, state),
};
}
private async loadState(): Promise<BackfillState> {
try {
const raw = await fs.readFile(this.statePath, 'utf8');
const parsed = JSON.parse(raw);
return {
cursors: parsed.cursors ?? {},
failures: parsed.failures ?? {},
metadata: parsed.metadata ?? {},
} satisfies BackfillState;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return {
cursors: {},
failures: {},
metadata: {},
};
}
throw error;
}
}
private async persistState(state: BackfillState) {
const serializable = {
cursors: state.cursors,
failures: state.failures,
metadata: state.metadata ?? {},
};
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
await fs.writeFile(
this.statePath,
JSON.stringify(serializable, null, 2),
'utf8',
);
}
private collectFailures(
entity: EntityKey,
state: BackfillState,
): BackfillFailure[] {
return Object.values(state.failures).filter(
(entry) => entry.entity === entity,
);
}
private failureKey(entity: EntityKey, id: string) {
return `${entity}:${id}`;
}
private serializeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
}

View File

@ -176,7 +176,7 @@ export class ObatService {
const data = {
id: `OBAT_${res.id}`,
event: 'obat_created',
user_id: userId.toString(),
user_id: userId,
payload: payloadHash,
};
const logResult = await this.logService.storeLog(data);

View File

@ -113,7 +113,7 @@ export class ProofService {
const response = await this.logService.storeLog({
id: `PROOF_${payload.id_visit}`,
event: 'proof_verification_logged',
user_id: '0', // External user
user_id: 'External',
payload: payloadHash,
});

View File

@ -55,7 +55,7 @@ const onSubmit = handleSubmit(async (values: any) => {
if (error && Array.isArray(error.message)) {
loginError.value = error.message[0];
} else {
loginError.value = error.message || "Terjadi kesalahan saat login.";
loginError.value = "Terjadi kesalahan. Silakan coba lagi.";
}
} finally {
isLoading.value = false;