[CLAUDE] PE-Workflow: UAT S22+1 — disable cả 3 button khi không quyền + BE guard
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s

User UAT feedback: "Nếu đã không được quyền thao tác thì ko được quyền thao tác
hết tất cả các hành động" — trước đây chỉ "Duyệt" disabled, "Trả lại" + "Từ chối"
vẫn enabled (design intent S17 cũ).

FE 2 app mirror (PeWorkflowPanel.tsx):
- `isDisabled = blockedByV2Level` (drop `isForwardApprove &&` qualifier)
- Tooltip update "mới thao tác được (Duyệt / Trả lại / Từ chối)"
- Comment refresh ghi UAT S22+1 spec + cross-ref BE EnsureCanRejectV2Async

BE defense-in-depth (PurchaseEvaluationWorkflowService.cs):
- Helper mới `EnsureCanRejectV2Async` mirror FE actorInV2Level logic:
  Skip silent khi admin/V1/non-ChoDuyet/no actor/no pointer. Throw
  ForbiddenException khi V2 + ChoDuyet + actor != currentLevel.ApproverUserId.
- Invoke ở top Reject branch (cover cả TuChoi + Trả lại sub-branches).
- Chặn request forge: non-approver gọi PATCH /transitions direct sẽ 403.

Test (test-before §7 — security guard critical algorithm):
- ReturnMode tests existing 7/7 vẫn PASS (a2.Id = currentLevel approver, guard accept)
- +1 NEW test `Reject_NonApprover_V2_Throws_ForbiddenException` — outsider
  Drafter role gọi Reject phiếu V2 → throw + Phase không mutate

Verify:
- dotnet test SolutionErp.slnx — 104/104 PASS (+1 guard regression)
  Δ: 103 → 104
- npm run build × 2 app — pass (482ms + 583ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-13 21:46:51 +07:00
parent a74e671431
commit 40f64c6b32
4 changed files with 96 additions and 10 deletions

View File

@ -74,6 +74,12 @@ public class PurchaseEvaluationWorkflowService(
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
if (decision == ApprovalDecision.Reject)
{
// UAT S22+1 — V2 actor scope guard (defense-in-depth).
// FE PeWorkflowPanel disable cả 3 button (Duyệt + Trả lại + Từ chối)
// khi actor không match currentLevel.ApproverUserId — BE mirror
// guard tránh request forge non-approver gọi PATCH direct.
await EnsureCanRejectV2Async(evaluation, actorUserId, isAdmin, ct);
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
@ -186,6 +192,43 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
}
// ===== V2 actor scope guard cho Reject (UAT S22+1) =====
// Mirror FE PeWorkflowPanel.actorInV2Level — chỉ Approver Cấp hiện tại (V2
// schema) hoặc Admin được Reject (Trả lại / Từ chối) phiếu V2. Defense-
// in-depth: UI đã disable cả 3 button nhưng BE chặn request forge non-
// approver gọi PATCH direct.
//
// Skip guard (silent return) khi điều kiện chưa đủ để check:
// - isAdmin = true → bypass
// - V1 schema (ApprovalWorkflowId null) → legacy behavior unchanged
// - Phase != ChoDuyet → reject từ phase khác (vd auto job system)
// - actorUserId null → system caller (vd cron)
// - Pointer chưa init (CurrentWorkflowStepIndex / CurrentApprovalLevelOrder null)
// → workflow chưa start, guard không relevant
private async Task EnsureCanRejectV2Async(
PurchaseEvaluation evaluation, Guid? actorUserId, bool isAdmin, CancellationToken ct)
{
if (isAdmin) return;
if (evaluation.ApprovalWorkflowId is not Guid awId) return;
if (evaluation.Phase != PurchaseEvaluationPhase.ChoDuyet) return;
if (actorUserId is not Guid actorId) return;
if (evaluation.CurrentWorkflowStepIndex is not int csi) return;
if (evaluation.CurrentApprovalLevelOrder is not int curLvl) return;
var workflow = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == awId, ct);
if (workflow is null) return; // schema lỗi — silent skip để LogTransition catch
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt
var step = stepsOrdered[csi];
var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
if (currentLevel?.ApproverUserId != actorId)
throw new ForbiddenException(
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
}
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
// (peer review chain). Mode Drafter set Phase=TraLai như S17.