tests: add unit test for validation module

This commit is contained in:
yosaphatprs 2025-12-11 13:54:11 +07:00
parent d11dc9b2a9
commit aa47a38c7a
6 changed files with 1423 additions and 23 deletions

View File

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

View File

@ -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}'`;

View File

@ -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>(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
*/

View File

@ -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,

File diff suppressed because it is too large Load Diff

View File

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