tests: add unit test for validation module
This commit is contained in:
parent
d11dc9b2a9
commit
aa47a38c7a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user