diff --git a/backend/api/src/modules/user/user.controller.spec.ts b/backend/api/src/modules/user/user.controller.spec.ts index a949f57..5be28ad 100644 --- a/backend/api/src/modules/user/user.controller.spec.ts +++ b/backend/api/src/modules/user/user.controller.spec.ts @@ -191,11 +191,15 @@ describe('UserController', () => { ); }); - // SECURITY ISSUE: No authentication guard on this endpoint - it('SECURITY: endpoint has no authentication guard', () => { - // This test documents that setCookie is publicly accessible - // Anyone can set cookies - might be intentional for demo, but risky - expect(true).toBe(true); // Placeholder - real security test would check guards + // SECURITY FIX: AuthGuard now protects this endpoint + it('should have AuthGuard protecting the endpoint', () => { + // This endpoint is now protected with @UseGuards(AuthGuard) + const guards = Reflect.getMetadata( + '__guards__', + UserController.prototype.setCookie, + ); + expect(guards).toBeDefined(); + expect(guards.length).toBeGreaterThan(0); }); }); @@ -233,10 +237,15 @@ describe('UserController', () => { expect(() => controller.getCookie(mockRequest)).toThrow(); }); - // SECURITY ISSUE: No authentication guard on this endpoint - it('SECURITY: endpoint has no authentication guard', () => { - // This test documents that getCookie is publicly accessible - expect(true).toBe(true); + // SECURITY FIX: AuthGuard now protects this endpoint + it('should have AuthGuard protecting the endpoint', () => { + // This endpoint is now protected with @UseGuards(AuthGuard) + const guards = Reflect.getMetadata( + '__guards__', + UserController.prototype.getCookie, + ); + expect(guards).toBeDefined(); + expect(guards.length).toBeGreaterThan(0); }); }); @@ -267,9 +276,18 @@ describe('UserController', () => { // Potential information disclosure if real data is returned }); - it('ISSUE: cookie endpoints have no guards - publicly accessible', () => { - // setCookie and getCookie have no authentication - // May be intentional for demo but worth reviewing + it('cookie endpoints now have AuthGuard protection', () => { + // setCookie and getCookie are now protected with AuthGuard + const setCookieGuards = Reflect.getMetadata( + '__guards__', + UserController.prototype.setCookie, + ); + const getCookieGuards = Reflect.getMetadata( + '__guards__', + UserController.prototype.getCookie, + ); + expect(setCookieGuards).toBeDefined(); + expect(getCookieGuards).toBeDefined(); }); }); }); @@ -293,9 +311,9 @@ describe('UserController', () => { * - User profile endpoint should require authentication * - Fix: Add @UseGuards(AuthGuard) to getUserProfile * - * 4. SECURITY - No guards on cookie endpoints: - * - setCookie and getCookie are publicly accessible - * - If demo code, consider removing; if needed, add guards + * 4. SECURITY - Cookie endpoints now protected: + * - setCookie and getCookie now have @UseGuards(AuthGuard) + * - FIXED: Added AuthGuard to both endpoints * * 5. ISSUE - No null check in getCookie: * - If req.cookies is undefined, accessing ['name'] throws diff --git a/backend/api/src/modules/user/user.controller.ts b/backend/api/src/modules/user/user.controller.ts index 5f5b7ed..55cca44 100644 --- a/backend/api/src/modules/user/user.controller.ts +++ b/backend/api/src/modules/user/user.controller.ts @@ -46,12 +46,14 @@ export class UserController { } @Get('/set-cookie') + @UseGuards(AuthGuard) setCookie(@Query('name') name: string, @Res() res: Response): void { res.cookie('name', name); res.status(200).send(`Cookie 'name' set to '${name}'`); } @Get('/get-cookie') + @UseGuards(AuthGuard) getCookie(@Req() req: Request): string { const name = req.cookies['name']; return `Cookie '${name}'`; diff --git a/backend/api/src/modules/validation/validation.controller.spec.ts b/backend/api/src/modules/validation/validation.controller.spec.ts index 37f8d1e..01bc76c 100644 --- a/backend/api/src/modules/validation/validation.controller.spec.ts +++ b/backend/api/src/modules/validation/validation.controller.spec.ts @@ -1,18 +1,409 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ValidationController } from './validation.controller'; +import { ValidationService } from './validation.service'; +import { AuthGuard } from '../auth/guard/auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; +import { UserRole } from '../auth/dto/auth.dto'; describe('ValidationController', () => { let controller: ValidationController; + let mockValidationService: { + getAllValidationsQueue: jest.Mock; + getValidationQueue: jest.Mock; + approveValidation: jest.Mock; + rejectValidation: jest.Mock; + }; + + const mockUser: ActiveUserPayload = { + sub: 1, + username: 'admin', + role: UserRole.Admin, + csrf: 'mock-csrf-token', + }; + + const mockValidationQueue = { + id: 1, + table_name: 'rekam_medis', + record_id: 'VISIT_001', + action: 'CREATE', + dataPayload: { id_visit: 'VISIT_001' }, + user_id_request: 2, + status: 'PENDING', + created_at: new Date(), + }; beforeEach(async () => { + mockValidationService = { + getAllValidationsQueue: jest.fn(), + getValidationQueue: jest.fn(), + approveValidation: jest.fn(), + rejectValidation: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [ValidationController], - }).compile(); + providers: [ + { provide: ValidationService, useValue: mockValidationService }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(ValidationController); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(controller).toBeDefined(); }); + + // ============================================================ + // getValidationStatus (GET /) + // ============================================================ + + describe('getValidationStatus', () => { + it('should call service with all query params', async () => { + const mockResponse = { data: [mockValidationQueue], totalCount: 1 }; + mockValidationService.getAllValidationsQueue.mockResolvedValue( + mockResponse, + ); + + const result = await controller.getValidationStatus( + 10, + 0, + 1, + 'created_at', + 'VISIT', + 'desc', + 'rekam_medis', + 'CREATE', + 'PENDING', + ); + + expect(mockValidationService.getAllValidationsQueue).toHaveBeenCalledWith( + { + take: 10, + skip: 0, + page: 1, + orderBy: 'created_at', + search: 'VISIT', + order: 'desc', + kelompok_data: 'rekam_medis', + aksi: 'CREATE', + status: 'PENDING', + }, + ); + expect(result).toEqual(mockResponse); + }); + + it('should handle undefined query params', async () => { + const mockResponse = { data: [], totalCount: 0 }; + mockValidationService.getAllValidationsQueue.mockResolvedValue( + mockResponse, + ); + + await controller.getValidationStatus( + undefined as unknown as number, + undefined as unknown as number, + undefined as unknown as number, + undefined as unknown as string, + undefined as unknown as string, + undefined as unknown as 'asc' | 'desc', + undefined as unknown as string, + undefined as unknown as string, + undefined as unknown as string, + ); + + expect(mockValidationService.getAllValidationsQueue).toHaveBeenCalledWith( + { + take: undefined, + skip: undefined, + page: undefined, + orderBy: undefined, + search: undefined, + order: undefined, + kelompok_data: undefined, + aksi: undefined, + status: undefined, + }, + ); + }); + + // ISSUE: Query params typed as number but come as strings + it('ISSUE: take/skip/page typed as number but come as strings from query', async () => { + mockValidationService.getAllValidationsQueue.mockResolvedValue({ + data: [], + totalCount: 0, + }); + + // In real scenario, these come as strings from query params + await controller.getValidationStatus( + '10' as any, + '0' as any, + '1' as any, + 'created_at', + 'search', + 'asc', + 'all', + 'all', + 'all', + ); + + // Values are passed as strings, not numbers + expect(mockValidationService.getAllValidationsQueue).toHaveBeenCalledWith( + expect.objectContaining({ + take: '10', + skip: '0', + page: '1', + }), + ); + }); + + it('should propagate service errors', async () => { + mockValidationService.getAllValidationsQueue.mockRejectedValue( + new Error('Database error'), + ); + + await expect( + controller.getValidationStatus( + 10, + 0, + 1, + 'created_at', + '', + 'asc', + 'all', + 'all', + 'all', + ), + ).rejects.toThrow('Database error'); + }); + }); + + // ============================================================ + // getValidationById (GET /:id) + // ============================================================ + + describe('getValidationById', () => { + it('should return validation by id', async () => { + mockValidationService.getValidationQueue.mockResolvedValue( + mockValidationQueue, + ); + + const result = await controller.getValidationById(1); + + expect(mockValidationService.getValidationQueue).toHaveBeenCalledWith(1); + expect(result).toEqual(mockValidationQueue); + }); + + it('should return null when validation not found', async () => { + mockValidationService.getValidationQueue.mockResolvedValue(null); + + const result = await controller.getValidationById(999); + + expect(result).toBeNull(); + }); + + // ISSUE: @Param('id') typed as number but comes as string + it('ISSUE: id param typed as number but comes as string without ParseIntPipe', async () => { + mockValidationService.getValidationQueue.mockResolvedValue( + mockValidationQueue, + ); + + // In real scenario, id comes as string from route param + await controller.getValidationById('1' as any); + + // Service receives string '1' instead of number 1 + expect(mockValidationService.getValidationQueue).toHaveBeenCalledWith( + '1', + ); + }); + + it('should propagate service errors', async () => { + mockValidationService.getValidationQueue.mockRejectedValue( + new Error('Service error'), + ); + + await expect(controller.getValidationById(1)).rejects.toThrow( + 'Service error', + ); + }); + }); + + // ============================================================ + // approveValidation (POST /:id/approve) + // ============================================================ + + describe('approveValidation', () => { + it('should approve validation with user context', async () => { + const approvedResult = { + ...mockValidationQueue, + status: 'APPROVED', + approvalResult: { id_visit: 'VISIT_001' }, + }; + mockValidationService.approveValidation.mockResolvedValue(approvedResult); + + const result = await controller.approveValidation(1, mockUser); + + expect(mockValidationService.approveValidation).toHaveBeenCalledWith( + 1, + mockUser, + ); + expect(result).toEqual(approvedResult); + }); + + // ISSUE: id param comes as string + it('ISSUE: id param typed as number but comes as string', async () => { + mockValidationService.approveValidation.mockResolvedValue({}); + + await controller.approveValidation('1' as any, mockUser); + + expect(mockValidationService.approveValidation).toHaveBeenCalledWith( + '1', + mockUser, + ); + }); + + it('should propagate service errors', async () => { + mockValidationService.approveValidation.mockRejectedValue( + new Error('Approval failed'), + ); + + await expect(controller.approveValidation(1, mockUser)).rejects.toThrow( + 'Approval failed', + ); + }); + }); + + // ============================================================ + // rejectValidation (POST /:id/reject) + // ============================================================ + + describe('rejectValidation', () => { + it('should reject validation with user context', async () => { + const rejectedResult = { + ...mockValidationQueue, + status: 'REJECTED', + }; + mockValidationService.rejectValidation.mockResolvedValue(rejectedResult); + + const result = await controller.rejectValidation(1, mockUser); + + expect(mockValidationService.rejectValidation).toHaveBeenCalledWith( + 1, + mockUser, + ); + expect(result).toEqual(rejectedResult); + }); + + // ISSUE: id param comes as string + it('ISSUE: id param typed as number but comes as string', async () => { + mockValidationService.rejectValidation.mockResolvedValue({}); + + await controller.rejectValidation('1' as any, mockUser); + + expect(mockValidationService.rejectValidation).toHaveBeenCalledWith( + '1', + mockUser, + ); + }); + + it('should propagate service errors', async () => { + mockValidationService.rejectValidation.mockRejectedValue( + new Error('Rejection failed'), + ); + + await expect(controller.rejectValidation(1, mockUser)).rejects.toThrow( + 'Rejection failed', + ); + }); + }); + + // ============================================================ + // Security Tests + // ============================================================ + + describe('Security', () => { + it('approveValidation should have AuthGuard and RolesGuard', () => { + // FIXED: approve endpoint now has RolesGuard with Admin role + const guards = Reflect.getMetadata( + '__guards__', + ValidationController.prototype.approveValidation, + ); + expect(guards).toBeDefined(); + expect(guards.length).toBe(2); // AuthGuard and RolesGuard + }); + + it('rejectValidation should have AuthGuard and RolesGuard', () => { + // FIXED: reject endpoint now has RolesGuard with Admin role + const guards = Reflect.getMetadata( + '__guards__', + ValidationController.prototype.rejectValidation, + ); + expect(guards).toBeDefined(); + expect(guards.length).toBe(2); // AuthGuard and RolesGuard + }); + + it('approveValidation should require Admin role', () => { + const roles = Reflect.getMetadata( + 'roles', + ValidationController.prototype.approveValidation, + ); + expect(roles).toContain(UserRole.Admin); + }); + + it('rejectValidation should require Admin role', () => { + const roles = Reflect.getMetadata( + 'roles', + ValidationController.prototype.rejectValidation, + ); + expect(roles).toContain(UserRole.Admin); + }); + + it('SECURITY: getValidationById returns null instead of 404', async () => { + // When validation not found, returns null instead of throwing NotFoundException + // This could leak information about valid/invalid IDs + mockValidationService.getValidationQueue.mockResolvedValue(null); + + const result = await controller.getValidationById(999); + + // Should throw NotFoundException instead + expect(result).toBeNull(); + }); + }); }); + +/* + * ============================================================ + * CODE ISSUES DOCUMENTATION + * ============================================================ + * + * 1. ISSUE - Query param type mismatch: + * - take, skip, page typed as `number` but come as strings + * - No ParseIntPipe used + * - Fix: Use @Query('take', ParseIntPipe) or parse in service + * + * 2. ISSUE - Route param type mismatch: + * - @Param('id') typed as `number` but comes as string + * - No ParseIntPipe used + * - Fix: Use @Param('id', ParseIntPipe) + * + * 3. SECURITY - Role-based access control implemented: + * - approve/reject endpoints now use AuthGuard + RolesGuard + * - Only Admin users can approve/reject validations + * - FIXED: Added @UseGuards(RolesGuard) and @Roles(UserRole.Admin) + * + * 4. ISSUE - getValidationById returns null: + * - Should throw NotFoundException for better REST semantics + * - Fix: Add null check and throw NotFoundException + * + * 5. SUGGESTION - Add validation for id parameter: + * - Consider adding validation that id is positive integer + */ diff --git a/backend/api/src/modules/validation/validation.controller.ts b/backend/api/src/modules/validation/validation.controller.ts index 13d944b..24d8b58 100644 --- a/backend/api/src/modules/validation/validation.controller.ts +++ b/backend/api/src/modules/validation/validation.controller.ts @@ -1,5 +1,8 @@ import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '../auth/guard/auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorator/roles.decorator'; +import { UserRole } from '../auth/dto/auth.dto'; import { ValidationService } from './validation.service'; import { CurrentUser } from '../auth/decorator/current-user.decorator'; import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; @@ -42,7 +45,8 @@ export class ValidationController { } @Post('/:id/approve') - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.Admin) async approveValidation( @Param('id') id: number, @CurrentUser() user: ActiveUserPayload, @@ -51,7 +55,8 @@ export class ValidationController { } @Post('/:id/reject') - @UseGuards(AuthGuard) + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.Admin) async rejectValidation( @Param('id') id: number, @CurrentUser() user: ActiveUserPayload, diff --git a/backend/api/src/modules/validation/validation.service.spec.ts b/backend/api/src/modules/validation/validation.service.spec.ts index 6b79f0b..847f370 100644 --- a/backend/api/src/modules/validation/validation.service.spec.ts +++ b/backend/api/src/modules/validation/validation.service.spec.ts @@ -1,18 +1,1001 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; import { ValidationService } from './validation.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RekammedisService } from '../rekammedis/rekammedis.service'; +import { TindakanDokterService } from '../tindakandokter/tindakandokter.service'; +import { ObatService } from '../obat/obat.service'; +import type { ActiveUserPayload } from '../auth/decorator/current-user.decorator'; +import { UserRole } from '../auth/dto/auth.dto'; describe('ValidationService', () => { let service: ValidationService; + let mockPrismaService: { + validation_queue: { + findMany: jest.Mock; + findUnique: jest.Mock; + count: jest.Mock; + update: jest.Mock; + }; + rekam_medis: { + findUnique: jest.Mock; + update: jest.Mock; + }; + pemberian_tindakan: { + findUnique: jest.Mock; + update: jest.Mock; + }; + pemberian_obat: { + findUnique: jest.Mock; + update: jest.Mock; + }; + $transaction: jest.Mock; + }; + let mockRekamMedisService: { + createRekamMedisToDBAndBlockchain: jest.Mock; + updateRekamMedisToDBAndBlockchain: jest.Mock; + deleteRekamMedisFromDBAndBlockchain: jest.Mock; + }; + let mockTindakanDokterService: { + createTindakanDokterToDBAndBlockchain: jest.Mock; + updateTindakanDokterToDBAndBlockchain: jest.Mock; + deleteTindakanDokterFromDBAndBlockchain: jest.Mock; + }; + let mockObatService: { + createObatToDBAndBlockchain: jest.Mock; + updateObatToDBAndBlockchain: jest.Mock; + deleteObatFromDBAndBlockchain: jest.Mock; + }; + + const mockUser: ActiveUserPayload = { + sub: 1, + username: 'admin', + role: UserRole.Admin, + csrf: 'mock-csrf-token', + }; + + const mockValidationQueue = { + id: 1, + table_name: 'rekam_medis', + record_id: 'VISIT_001', + action: 'CREATE', + dataPayload: { id_visit: 'VISIT_001', nama_pasien: 'John Doe' }, + user_id_request: 2, + user_id_process: null, + status: 'PENDING', + created_at: new Date('2024-01-01'), + processed_at: null, + }; beforeEach(async () => { + mockPrismaService = { + validation_queue: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }, + rekam_medis: { + findUnique: jest.fn(), + update: jest.fn(), + }, + pemberian_tindakan: { + findUnique: jest.fn(), + update: jest.fn(), + }, + pemberian_obat: { + findUnique: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }; + + mockRekamMedisService = { + createRekamMedisToDBAndBlockchain: jest.fn(), + updateRekamMedisToDBAndBlockchain: jest.fn(), + deleteRekamMedisFromDBAndBlockchain: jest.fn(), + }; + + mockTindakanDokterService = { + createTindakanDokterToDBAndBlockchain: jest.fn(), + updateTindakanDokterToDBAndBlockchain: jest.fn(), + deleteTindakanDokterFromDBAndBlockchain: jest.fn(), + }; + + mockObatService = { + createObatToDBAndBlockchain: jest.fn(), + updateObatToDBAndBlockchain: jest.fn(), + deleteObatFromDBAndBlockchain: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [ValidationService], + providers: [ + ValidationService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: RekammedisService, useValue: mockRekamMedisService }, + { provide: TindakanDokterService, useValue: mockTindakanDokterService }, + { provide: ObatService, useValue: mockObatService }, + ], }).compile(); service = module.get(ValidationService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + + // ============================================================ + // getAllValidationsQueue + // ============================================================ + + describe('getAllValidationsQueue', () => { + const mockQueues = [mockValidationQueue]; + + it('should return validation queues with pagination', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue(mockQueues); + mockPrismaService.validation_queue.count.mockResolvedValue(1); + + const result = await service.getAllValidationsQueue({ + take: 10, + page: 1, + }); + + expect(result).toEqual({ data: mockQueues, totalCount: 1 }); + expect(mockPrismaService.validation_queue.findMany).toHaveBeenCalled(); + expect(mockPrismaService.validation_queue.count).toHaveBeenCalled(); + }); + + it('should calculate skip from page parameter', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ take: 10, page: 3 }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.skip).toBe(20); // (3-1) * 10 + }); + + it('should use explicit skip over page', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ take: 10, skip: 5, page: 3 }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.skip).toBe(5); + }); + + it('should filter by kelompok_data (table_name)', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ kelompok_data: 'Rekam_Medis' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.where.table_name).toBe('rekam_medis'); + }); + + it('should ignore kelompok_data when set to "all"', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ kelompok_data: 'all' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.where.table_name).toBeUndefined(); + }); + + it('should filter by aksi (action) uppercase', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ aksi: 'create' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.where.action).toBe('CREATE'); + }); + + it('should filter by status uppercase', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ status: 'pending' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.where.status).toBe('PENDING'); + }); + + it('should filter by search (record_id contains)', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ search: 'VISIT' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.where.record_id).toEqual({ contains: 'VISIT' }); + }); + + it('should apply custom orderBy', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({ orderBy: 'id', order: 'desc' }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.orderBy).toEqual({ id: 'desc' }); + }); + + it('should default orderBy to created_at desc', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + await service.getAllValidationsQueue({}); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + expect(callArgs.orderBy).toEqual({ created_at: 'desc' }); + }); + + // BUG TEST: take is not parsed as integer + describe('BUG: take is not parsed as integer', () => { + it('should pass take directly without parseInt', async () => { + mockPrismaService.validation_queue.findMany.mockResolvedValue([]); + mockPrismaService.validation_queue.count.mockResolvedValue(0); + + // When take comes as string from query params + await service.getAllValidationsQueue({ take: '10' as any }); + + const callArgs = + mockPrismaService.validation_queue.findMany.mock.calls[0][0]; + // FIXED: take is now correctly parsed as number 10 + expect(callArgs.take).toBe(10); + }); + }); + }); + + // ============================================================ + // getAllValidationQueueDashboard + // ============================================================ + + describe('getAllValidationQueueDashboard', () => { + it('should return pending validations limited to 5', async () => { + const pendingQueues = [mockValidationQueue]; + mockPrismaService.validation_queue.findMany.mockResolvedValue( + pendingQueues, + ); + mockPrismaService.validation_queue.count.mockResolvedValue(10); + + const result = await service.getAllValidationQueueDashboard(); + + expect(result).toEqual({ data: pendingQueues, totalCount: 10 }); + expect(mockPrismaService.validation_queue.findMany).toHaveBeenCalledWith({ + where: { status: 'PENDING' }, + take: 5, + orderBy: { created_at: 'desc' }, + }); + }); + }); + + // ============================================================ + // getValidationQueueById (internal) + // ============================================================ + + describe('getValidationQueueById', () => { + it('should return validation queue by id', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + mockValidationQueue, + ); + + const result = await service.getValidationQueueById(1); + + expect(result).toEqual(mockValidationQueue); + expect( + mockPrismaService.validation_queue.findUnique, + ).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); + + it('should return null when not found', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue(null); + + const result = await service.getValidationQueueById(999); + + expect(result).toBeNull(); + }); + }); + + // ============================================================ + // getValidationQueue (front-end detail view) + // ============================================================ + + describe('getValidationQueue', () => { + it('should return null when validation not found', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue(null); + + const result = await service.getValidationQueue(999); + + expect(result).toBeNull(); + }); + + it('should return validation without previousLog for CREATE action', async () => { + const createQueue = { ...mockValidationQueue, action: 'CREATE' }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + createQueue, + ); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual(createQueue); + // Should not fetch previous log for CREATE + expect(mockPrismaService.rekam_medis.findUnique).not.toHaveBeenCalled(); + }); + + it('should return validation with previousLog for UPDATE action - rekam_medis', async () => { + const updateQueue = { ...mockValidationQueue, action: 'UPDATE' }; + const previousData = { id_visit: 'VISIT_001', nama_pasien: 'Old Name' }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockPrismaService.rekam_medis.findUnique.mockResolvedValue(previousData); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual({ previousLog: previousData, ...updateQueue }); + expect(mockPrismaService.rekam_medis.findUnique).toHaveBeenCalledWith({ + where: { id_visit: 'VISIT_001' }, + }); + }); + + it('should return validation with previousLog for UPDATE action - pemberian_tindakan', async () => { + const updateQueue = { + ...mockValidationQueue, + table_name: 'pemberian_tindakan', + record_id: '123', + action: 'UPDATE', + }; + const previousData = { id: 123, tindakan: 'Old Tindakan' }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockPrismaService.pemberian_tindakan.findUnique.mockResolvedValue( + previousData, + ); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual({ previousLog: previousData, ...updateQueue }); + expect( + mockPrismaService.pemberian_tindakan.findUnique, + ).toHaveBeenCalledWith({ + where: { id: 123 }, + }); + }); + + it('should return validation with previousLog for UPDATE action - pemberian_obat', async () => { + const updateQueue = { + ...mockValidationQueue, + table_name: 'pemberian_obat', + record_id: '456', + action: 'UPDATE', + }; + const previousData = { id: 456, obat: 'Old Obat' }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockPrismaService.pemberian_obat.findUnique.mockResolvedValue( + previousData, + ); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual({ previousLog: previousData, ...updateQueue }); + }); + + it('should return null previousLog for unknown table_name', async () => { + const updateQueue = { + ...mockValidationQueue, + table_name: 'unknown_table', + action: 'UPDATE', + }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual({ previousLog: null, ...updateQueue }); + }); + + it('should return null previousLog when record_id is null', async () => { + const updateQueue = { + ...mockValidationQueue, + record_id: null, + action: 'UPDATE', + }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + + const result = await service.getValidationQueue(1); + + expect(result).toEqual({ previousLog: null, ...updateQueue }); + }); + }); + + // ============================================================ + // approveValidation + // ============================================================ + + describe('approveValidation', () => { + describe('rekam_medis handlers', () => { + it('should approve CREATE for rekam_medis', async () => { + const createQueue = { + ...mockValidationQueue, + action: 'CREATE', + table_name: 'rekam_medis', + }; + const createdResult = { id_visit: 'VISIT_001', nama_pasien: 'John' }; + const updatedQueue = { ...createQueue, status: 'APPROVED' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + createQueue, + ); + mockRekamMedisService.createRekamMedisToDBAndBlockchain.mockResolvedValue( + createdResult, + ); + mockPrismaService.validation_queue.update.mockResolvedValue( + updatedQueue, + ); + + const result = await service.approveValidation(1, mockUser); + + expect( + mockRekamMedisService.createRekamMedisToDBAndBlockchain, + ).toHaveBeenCalledWith(createQueue.dataPayload, 2); + expect(mockPrismaService.validation_queue.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: expect.objectContaining({ + record_id: 'VISIT_001', + status: 'APPROVED', + user_id_process: 1, + }), + }); + }); + + it('should approve UPDATE for rekam_medis', async () => { + const updateQueue = { + ...mockValidationQueue, + action: 'UPDATE', + table_name: 'rekam_medis', + }; + const updatedResult = { id_visit: 'VISIT_001' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockRekamMedisService.updateRekamMedisToDBAndBlockchain.mockResolvedValue( + updatedResult, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...updateQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect( + mockRekamMedisService.updateRekamMedisToDBAndBlockchain, + ).toHaveBeenCalledWith('VISIT_001', updateQueue.dataPayload, 2); + }); + + it('should approve DELETE for rekam_medis', async () => { + const deleteQueue = { + ...mockValidationQueue, + action: 'DELETE', + table_name: 'rekam_medis', + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + mockRekamMedisService.deleteRekamMedisFromDBAndBlockchain.mockResolvedValue( + {}, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...deleteQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect( + mockRekamMedisService.deleteRekamMedisFromDBAndBlockchain, + ).toHaveBeenCalledWith('VISIT_001', 2); + }); + }); + + describe('pemberian_tindakan handlers', () => { + const tindakanQueue = { + ...mockValidationQueue, + table_name: 'pemberian_tindakan', + record_id: '123', + dataPayload: { tindakan: 'Test Tindakan' }, + }; + + it('should approve CREATE for pemberian_tindakan', async () => { + const createQueue = { ...tindakanQueue, action: 'CREATE' }; + const createdResult = { id: 123 }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + createQueue, + ); + mockTindakanDokterService.createTindakanDokterToDBAndBlockchain.mockResolvedValue( + createdResult, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...createQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect( + mockTindakanDokterService.createTindakanDokterToDBAndBlockchain, + ).toHaveBeenCalled(); + }); + + it('should approve UPDATE for pemberian_tindakan', async () => { + const updateQueue = { ...tindakanQueue, action: 'UPDATE' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockTindakanDokterService.updateTindakanDokterToDBAndBlockchain.mockResolvedValue( + { id: 123 }, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...updateQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect( + mockTindakanDokterService.updateTindakanDokterToDBAndBlockchain, + ).toHaveBeenCalledWith('123', updateQueue.dataPayload, 2); + }); + + // BUG TEST: DELETE handler passes user_id_request without Number() conversion + it('BUG: DELETE for pemberian_tindakan passes user_id without Number() conversion', async () => { + const deleteQueue = { + ...tindakanQueue, + action: 'DELETE', + user_id_request: 2, + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + mockTindakanDokterService.deleteTindakanDokterFromDBAndBlockchain.mockResolvedValue( + { id: 123 }, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...deleteQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + // BUG: Second argument is NOT wrapped in Number() like other handlers + expect( + mockTindakanDokterService.deleteTindakanDokterFromDBAndBlockchain, + ).toHaveBeenCalledWith(123, 2); // Should be Number(queue.user_id_request) + }); + }); + + describe('pemberian_obat handlers', () => { + const obatQueue = { + ...mockValidationQueue, + table_name: 'pemberian_obat', + record_id: '456', + dataPayload: { obat: 'Test Obat' }, + }; + + it('should approve CREATE for pemberian_obat', async () => { + const createQueue = { ...obatQueue, action: 'CREATE' }; + const createdResult = { id: 456 }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + createQueue, + ); + mockObatService.createObatToDBAndBlockchain.mockResolvedValue( + createdResult, + ); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...createQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect(mockObatService.createObatToDBAndBlockchain).toHaveBeenCalled(); + }); + + it('should approve UPDATE for pemberian_obat', async () => { + const updateQueue = { ...obatQueue, action: 'UPDATE' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + updateQueue, + ); + mockObatService.updateObatToDBAndBlockchain.mockResolvedValue({ + id: 456, + }); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...updateQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + expect( + mockObatService.updateObatToDBAndBlockchain, + ).toHaveBeenCalledWith('456', updateQueue.dataPayload, 2); + }); + + // BUG TEST: DELETE handler passes user_id_request without Number() conversion + it('BUG: DELETE for pemberian_obat passes user_id without Number() conversion', async () => { + const deleteQueue = { ...obatQueue, action: 'DELETE' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + mockObatService.deleteObatFromDBAndBlockchain.mockResolvedValue({ + id: 456, + }); + mockPrismaService.validation_queue.update.mockResolvedValue({ + ...deleteQueue, + status: 'APPROVED', + }); + + await service.approveValidation(1, mockUser); + + // BUG: Same issue - second argument not wrapped in Number() + expect( + mockObatService.deleteObatFromDBAndBlockchain, + ).toHaveBeenCalledWith(456, 2); + }); + }); + + describe('error handling', () => { + it('should throw BadRequestException when validation not found', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue(null); + + await expect(service.approveValidation(999, mockUser)).rejects.toThrow( + BadRequestException, + ); + await expect(service.approveValidation(999, mockUser)).rejects.toThrow( + 'Validation queue not found', + ); + }); + + it('should throw Error when dataPayload is missing', async () => { + const queueWithoutPayload = { + ...mockValidationQueue, + dataPayload: null, + }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + queueWithoutPayload, + ); + + await expect(service.approveValidation(1, mockUser)).rejects.toThrow( + 'Data payload is missing', + ); + }); + + it('should throw Error for unsupported table', async () => { + const unsupportedQueue = { + ...mockValidationQueue, + table_name: 'unsupported_table', + }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + unsupportedQueue, + ); + + await expect(service.approveValidation(1, mockUser)).rejects.toThrow( + 'Unsupported table', + ); + }); + + it('should throw Error for unknown action', async () => { + const unknownActionQueue = { + ...mockValidationQueue, + action: 'UNKNOWN', + }; + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + unknownActionQueue, + ); + + await expect(service.approveValidation(1, mockUser)).rejects.toThrow( + 'Unknown action', + ); + }); + + it('should propagate blockchain service errors', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + mockValidationQueue, + ); + mockRekamMedisService.createRekamMedisToDBAndBlockchain.mockRejectedValue( + new Error('Blockchain error'), + ); + + await expect(service.approveValidation(1, mockUser)).rejects.toThrow( + 'Blockchain error', + ); + }); + }); + }); + + // ============================================================ + // rejectValidation + // ============================================================ + + describe('rejectValidation', () => { + it('should reject validation and update queue status', async () => { + const pendingQueue = { ...mockValidationQueue, action: 'CREATE' }; + const rejectedQueue = { ...pendingQueue, status: 'REJECTED' }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + pendingQueue, + ); + mockPrismaService.$transaction.mockImplementation(async (callback) => { + return callback({ + validation_queue: { + update: jest.fn().mockResolvedValue(rejectedQueue), + }, + rekam_medis: { update: jest.fn() }, + pemberian_tindakan: { update: jest.fn() }, + pemberian_obat: { update: jest.fn() }, + }); + }); + + const result = await service.rejectValidation(1, mockUser); + + expect(result.status).toBe('REJECTED'); + }); + + it('should reset deleted_status for DELETE action - rekam_medis', async () => { + const deleteQueue = { + ...mockValidationQueue, + action: 'DELETE', + status: 'PENDING', + table_name: 'rekam_medis', + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + + const mockTx = { + validation_queue: { + update: jest + .fn() + .mockResolvedValue({ ...deleteQueue, status: 'REJECTED' }), + }, + rekam_medis: { + update: jest.fn().mockResolvedValue({ deleted_status: null }), + }, + pemberian_tindakan: { update: jest.fn() }, + pemberian_obat: { update: jest.fn() }, + }; + mockPrismaService.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + + await service.rejectValidation(1, mockUser); + + expect(mockTx.rekam_medis.update).toHaveBeenCalledWith({ + where: { id_visit: 'VISIT_001' }, + data: { deleted_status: null }, + }); + }); + + it('should reset deleted_status for DELETE action - pemberian_tindakan', async () => { + const deleteQueue = { + ...mockValidationQueue, + action: 'DELETE', + status: 'PENDING', + table_name: 'pemberian_tindakan', + record_id: '123', + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + + const mockTx = { + validation_queue: { + update: jest + .fn() + .mockResolvedValue({ ...deleteQueue, status: 'REJECTED' }), + }, + rekam_medis: { update: jest.fn() }, + pemberian_tindakan: { + update: jest.fn().mockResolvedValue({ deleted_status: null }), + }, + pemberian_obat: { update: jest.fn() }, + }; + mockPrismaService.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + + await service.rejectValidation(1, mockUser); + + expect(mockTx.pemberian_tindakan.update).toHaveBeenCalledWith({ + where: { id: 123 }, + data: { deleted_status: null }, + }); + }); + + it('should reset deleted_status for DELETE action - pemberian_obat', async () => { + const deleteQueue = { + ...mockValidationQueue, + action: 'DELETE', + status: 'PENDING', + table_name: 'pemberian_obat', + record_id: '456', + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + + const mockTx = { + validation_queue: { + update: jest + .fn() + .mockResolvedValue({ ...deleteQueue, status: 'REJECTED' }), + }, + rekam_medis: { update: jest.fn() }, + pemberian_tindakan: { update: jest.fn() }, + pemberian_obat: { + update: jest.fn().mockResolvedValue({ deleted_status: null }), + }, + }; + mockPrismaService.$transaction.mockImplementation(async (callback) => + callback(mockTx), + ); + + await service.rejectValidation(1, mockUser); + + expect(mockTx.pemberian_obat.update).toHaveBeenCalledWith({ + where: { id: 456 }, + data: { deleted_status: null }, + }); + }); + + describe('error handling', () => { + // INCONSISTENCY TEST: rejectValidation throws Error, but approveValidation throws BadRequestException + it('INCONSISTENCY: throws generic Error instead of BadRequestException when not found', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue(null); + + // This throws generic Error, but approveValidation throws BadRequestException + await expect(service.rejectValidation(999, mockUser)).rejects.toThrow( + Error, + ); + await expect(service.rejectValidation(999, mockUser)).rejects.toThrow( + 'Validation queue not found', + ); + }); + + it('should throw for unsupported table on DELETE rejection', async () => { + const deleteQueue = { + ...mockValidationQueue, + action: 'DELETE', + status: 'PENDING', + table_name: 'unsupported_table', + }; + + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + deleteQueue, + ); + + // Error is thrown in determineIdType before reaching transaction + await expect(service.rejectValidation(1, mockUser)).rejects.toThrow( + 'Unsupported table for ID determination', + ); + }); + + it('should propagate transaction errors', async () => { + mockPrismaService.validation_queue.findUnique.mockResolvedValue( + mockValidationQueue, + ); + mockPrismaService.$transaction.mockRejectedValue( + new Error('Transaction failed'), + ); + + await expect(service.rejectValidation(1, mockUser)).rejects.toThrow( + 'Transaction failed', + ); + }); + }); + }); + + // ============================================================ + // determineIdType + // ============================================================ + + describe('determineIdType', () => { + it('should return string for rekam_medis', () => { + const result = service.determineIdType('rekam_medis', 'VISIT_001'); + expect(result).toBe('VISIT_001'); + expect(typeof result).toBe('string'); + }); + + it('should return number for pemberian_tindakan', () => { + const result = service.determineIdType('pemberian_tindakan', '123'); + expect(result).toBe(123); + expect(typeof result).toBe('number'); + }); + + it('should return number for pemberian_obat', () => { + const result = service.determineIdType('pemberian_obat', '456'); + expect(result).toBe(456); + expect(typeof result).toBe('number'); + }); + + it('should throw for unsupported table', () => { + expect(() => service.determineIdType('unknown_table', '123')).toThrow( + 'Unsupported table for ID determination', + ); + }); + }); }); + +/* + * ============================================================ + * CODE ISSUES DOCUMENTATION + * ============================================================ + * + * 1. BUG - take not parsed: + * - getAllValidationsQueue: `take` passed directly without parseInt() + * - skip/page are parsed but take is not + * - Fix: Add parseInt(take.toString()) like skip + * + * 2. INCONSISTENCY - Error types: + * - approveValidation throws BadRequestException for "not found" + * - rejectValidation throws generic Error for "not found" + * - Fix: Use BadRequestException consistently + * + * 3. INCONSISTENCY - Error for missing payload: + * - approveValidation throws generic Error for "Data payload is missing" + * - Should use BadRequestException for input validation + * + * 4. BUG - Missing Number() conversion: + * - pemberian_tindakan.approveDelete: passes queue.user_id_request directly + * - pemberian_obat.approveDelete: same issue + * - Other handlers use Number(queue.user_id_request) + * - Fix: Wrap in Number() for consistency + * + * 5. ISSUE - getValidationQueue returns null: + * - Returns null instead of throwing NotFoundException + * - Controller should handle null check + * + * 6. ISSUE - Excessive use of `any` type: + * - handlers use `any` for queue parameter + * - params in getAllValidationsQueue is typed as `any` + * - Reduces type safety + */ diff --git a/backend/api/src/modules/validation/validation.service.ts b/backend/api/src/modules/validation/validation.service.ts index f43c428..79dbdd7 100644 --- a/backend/api/src/modules/validation/validation.service.ts +++ b/backend/api/src/modules/validation/validation.service.ts @@ -76,7 +76,7 @@ export class ValidationService { approveDelete: async (queue: any) => { return this.tindakanDokterService.deleteTindakanDokterFromDBAndBlockchain( Number(queue.record_id), - queue.user_id_request, + Number(queue.user_id_request), ); }, }, @@ -103,7 +103,7 @@ export class ValidationService { approveDelete: async (queue: any) => { return this.obatService.deleteObatFromDBAndBlockchain( Number(queue.record_id), - queue.user_id_request, + Number(queue.user_id_request), ); }, }, @@ -124,11 +124,12 @@ export class ValidationService { const skipValue = skip ? parseInt(skip.toString()) : page - ? (parseInt(page.toString()) - 1) * take + ? (parseInt(page.toString()) - 1) * parseInt(take?.toString() || '10') : 0; + const takeValue = take ? parseInt(take.toString()) : undefined; console.log('Params', params); const result = await this.prisma.validation_queue.findMany({ - take, + take: takeValue, skip: skipValue, orderBy: orderBy ? { [orderBy]: order || 'asc' } : { created_at: 'desc' }, where: {