690503:0135 Update workflow #01
This commit is contained in:
@@ -34,9 +34,11 @@ describe('WorkflowTransitionGuard', () => {
|
||||
|
||||
const mockRequest = (
|
||||
params: Record<string, string> = {},
|
||||
user: MockUserPayload = mockUser
|
||||
user: MockUserPayload = mockUser,
|
||||
action = 'APPROVE'
|
||||
): Partial<RequestWithUser> => ({
|
||||
params,
|
||||
body: { action },
|
||||
user: user as RequestWithUser['user'],
|
||||
});
|
||||
|
||||
@@ -120,6 +122,7 @@ describe('WorkflowTransitionGuard', () => {
|
||||
expect(userService.getUserPermissions).toHaveBeenCalledWith(123);
|
||||
expect(instanceRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'instance-123' },
|
||||
relations: ['definition'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,6 +279,130 @@ describe('WorkflowTransitionGuard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// T025: DSL require.role → CASL ability mapping tests
|
||||
describe('DSL CASL Role Mapping (FR-002a)', () => {
|
||||
it('should allow access when DSL requires OrgAdmin role and user has organization.manage_users', async () => {
|
||||
userService.getUserPermissions.mockResolvedValue([
|
||||
'organization.manage_users',
|
||||
]);
|
||||
const mockInstance = {
|
||||
id: 'instance-dsl-1',
|
||||
currentState: 'PENDING_REVIEW',
|
||||
context: { organizationId: 99 }, // Different org — Level 2 would deny
|
||||
contractId: null,
|
||||
definition: {
|
||||
compiled: {
|
||||
states: {
|
||||
PENDING_REVIEW: {
|
||||
transitions: {
|
||||
APPROVE: { requirements: { roles: ['OrgAdmin'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
instanceRepo.findOne.mockResolvedValue(mockInstance);
|
||||
const context = mockContext(
|
||||
mockRequest({ id: 'instance-dsl-1' }, mockUser, 'APPROVE')
|
||||
);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow access when DSL requires ContractMember and user has contract.view', async () => {
|
||||
userService.getUserPermissions.mockResolvedValue(['contract.view']);
|
||||
const mockInstance = {
|
||||
id: 'instance-dsl-2',
|
||||
currentState: 'REVIEW',
|
||||
context: { organizationId: 99 },
|
||||
contractId: null,
|
||||
definition: {
|
||||
compiled: {
|
||||
states: {
|
||||
REVIEW: {
|
||||
transitions: {
|
||||
SUBMIT: { requirements: { roles: ['ContractMember'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
instanceRepo.findOne.mockResolvedValue(mockInstance);
|
||||
const context = mockContext(
|
||||
mockRequest({ id: 'instance-dsl-2' }, mockUser, 'SUBMIT')
|
||||
);
|
||||
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when DSL requires OrgAdmin but user only has contract.view', async () => {
|
||||
userService.getUserPermissions.mockResolvedValue(['contract.view']);
|
||||
const mockInstance = {
|
||||
id: 'instance-dsl-3',
|
||||
currentState: 'PENDING',
|
||||
context: { organizationId: 99 },
|
||||
contractId: null,
|
||||
definition: {
|
||||
compiled: {
|
||||
states: {
|
||||
PENDING: {
|
||||
transitions: {
|
||||
APPROVE: { requirements: { roles: ['OrgAdmin'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
instanceRepo.findOne.mockResolvedValue(mockInstance);
|
||||
const context = mockContext(
|
||||
mockRequest({ id: 'instance-dsl-3' }, mockUser, 'APPROVE')
|
||||
);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall through to Level 3 when DSL role is AssignedHandler', async () => {
|
||||
userService.getUserPermissions.mockResolvedValue(['document.view']);
|
||||
const mockInstance = {
|
||||
id: 'instance-dsl-4',
|
||||
currentState: 'ASSIGNED',
|
||||
context: { organizationId: 99, assignedUserId: 123 }, // same as mockUser.user_id
|
||||
contractId: null,
|
||||
definition: {
|
||||
compiled: {
|
||||
states: {
|
||||
ASSIGNED: {
|
||||
transitions: {
|
||||
COMPLETE: {
|
||||
requirements: { roles: ['AssignedHandler'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
instanceRepo.findOne.mockResolvedValue(mockInstance);
|
||||
const context = mockContext(
|
||||
mockRequest({ id: 'instance-dsl-4' }, mockUser, 'COMPLETE')
|
||||
);
|
||||
|
||||
// AssignedHandler → falls to Level 3 check → passes because assignedUserId === user_id
|
||||
const result = await guard.canActivate(context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Level 4: Unauthorized Users', () => {
|
||||
it('should deny access for regular users without any special permissions', async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -12,7 +12,17 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { WorkflowInstance } from '../entities/workflow-instance.entity';
|
||||
import { CompiledWorkflow } from '../workflow-dsl.service';
|
||||
import { UserService } from '../../../modules/user/user.service';
|
||||
|
||||
// FR-002a: DSL require.role → CASL ability สตาติก mapping (research.md Decision 2)
|
||||
// 'ไม่รู้จัก' DSL role → fall through ไป Level 3 (assignedUserId) check
|
||||
const DSL_ROLE_TO_CASL: Record<string, string> = {
|
||||
Superadmin: 'system.manage_all',
|
||||
OrgAdmin: 'organization.manage_users',
|
||||
ContractMember: 'contract.view',
|
||||
AssignedHandler: '__assigned__', // ไม่ map ไป CASL — จัดการโดย Level 3 check
|
||||
};
|
||||
import type { RequestWithUser } from '../../../common/interfaces/request-with-user.interface';
|
||||
|
||||
/**
|
||||
@@ -39,6 +49,8 @@ export class WorkflowTransitionGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const instanceId = request.params['id'];
|
||||
// FR-002a: action \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a DSL role check (\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a requirements.roles \u0e02\u0e2d\u0e07 transition \u0e17\u0e35\u0e48\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e17\u0e33)
|
||||
const action = (request.body as { action?: string }).action ?? '';
|
||||
const user = request.user;
|
||||
|
||||
// ดึงสิทธิ์ทั้งหมดของ User จาก DB (ตาม pattern เดียวกับ RbacGuard)
|
||||
@@ -51,15 +63,37 @@ export class WorkflowTransitionGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ดึง Instance เพื่อตรวจสอบ Context
|
||||
// ดึง Instance + Definition เพื่อตรวจสอบ Context และ DSL require.role
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { id: instanceId },
|
||||
relations: ['definition'],
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException('Workflow Instance', instanceId);
|
||||
}
|
||||
|
||||
// FR-002a: DSL require.role → CASL ability check
|
||||
// ตรวจสอบ requirements.roles ของ CompiledTransition ที่ตรงกับ action ที่ Request ขอ
|
||||
// (ยังต้องผ่าน contract membership check Level 2.5)
|
||||
const compiled = instance.definition?.compiled as
|
||||
| CompiledWorkflow
|
||||
| undefined;
|
||||
const stateConfig = compiled?.states?.[instance.currentState];
|
||||
// CompiledTransition.requirements.roles — ไม่ใช่ stateConfig.require (ซึ่งไม่มี)
|
||||
const requiredDslRoles: string[] =
|
||||
stateConfig?.transitions?.[action]?.requirements?.roles ?? [];
|
||||
let dslRoleAuthorized = false;
|
||||
for (const dslRole of requiredDslRoles) {
|
||||
const caslAbility = DSL_ROLE_TO_CASL[dslRole];
|
||||
if (caslAbility && caslAbility !== '__assigned__') {
|
||||
if (userPermissions.includes(caslAbility)) {
|
||||
dslRoleAuthorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2: Org Admin — organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร
|
||||
const docOrgId = instance.context?.organizationId as number | undefined;
|
||||
if (
|
||||
@@ -99,16 +133,21 @@ export class WorkflowTransitionGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
// Level 3: Assigned Handler — User นี้ถูก Assign มาให้ทำ Step นี้โดยตรง
|
||||
// Level 3: Assigned Handler หรือ DSL CASL-authorized role
|
||||
// FR-002a: ถ้า DSL require.role ตรงกับ CASL ability ของ User → ผ่าน
|
||||
// (กรณี AssignedHandler ใน DSL → ตรวจสอบผ่าน assignedUserId ใน context)
|
||||
const assignedUserId = instance.context?.assignedUserId as
|
||||
| number
|
||||
| undefined;
|
||||
if (assignedUserId !== undefined && user.user_id === assignedUserId) {
|
||||
if (
|
||||
dslRoleAuthorized ||
|
||||
(assignedUserId !== undefined && user.user_id === assignedUserId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId}`
|
||||
`Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId} (DSL roles: [${requiredDslRoles.join(', ')}])`
|
||||
);
|
||||
throw new ForbiddenException({
|
||||
userMessage: 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้',
|
||||
|
||||
Reference in New Issue
Block a user