From 40f64c6b325cd2b6d8952baf4747069bb801ad47 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 13 May 2026 21:46:51 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PE-Workflow:=20UAT=20S22+1=20?= =?UTF-8?q?=E2=80=94=20disable=20c=E1=BA=A3=203=20button=20khi=20kh=C3=B4n?= =?UTF-8?q?g=20quy=E1=BB=81n=20+=20BE=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/pe/PeWorkflowPanel.tsx | 14 +++--- fe-user/src/components/pe/PeWorkflowPanel.tsx | 11 +++-- .../PurchaseEvaluationWorkflowService.cs | 43 +++++++++++++++++++ ...valuationWorkflowServiceReturnModeTests.cs | 38 ++++++++++++++++ 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index e47876b..5f67745 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -49,9 +49,10 @@ export function PeWorkflowPanel({ .filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i) // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers - // duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward" - // (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2 - // hành động này — Approver có thể reject bất cứ lúc nào trong phiên). + // thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền + // mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor + // không match currentLevel.ApproverUserId. BE mirror guard trong + // EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject). // Admin bypass. const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorInV2Level = isAdmin @@ -239,11 +240,12 @@ export function PeWorkflowPanel({ && evaluation.phase !== PurchaseEvaluationPhase.TraLai const isCancel = p === PurchaseEvaluationPhase.TuChoi const isForwardApprove = !isSendBack && !isCancel - // Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại - const isDisabled = isForwardApprove && blockedByV2Level + // Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match + // currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động." + const isDisabled = blockedByV2Level const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt' const title = isDisabled && evaluation.currentApproval - ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.` + ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới thao tác được (Duyệt / Trả lại / Từ chối).` : isForwardApprove ? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}` : undefined diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index 9a0e62b..41c8d0c 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -51,7 +51,9 @@ export function PeWorkflowPanel({ .filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i) // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers - // duyệt cấp hiện tại. Admin bypass. + // thao tác cấp hiện tại. UAT S22+1: disable cả 3 button (Duyệt + Trả lại + // + Từ chối) khi actor không match. BE mirror EnsureCanRejectV2Async. + // Admin bypass. const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)) @@ -235,11 +237,12 @@ export function PeWorkflowPanel({ && evaluation.phase !== PurchaseEvaluationPhase.TraLai const isCancel = p === PurchaseEvaluationPhase.TuChoi const isForwardApprove = !isSendBack && !isCancel - // Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại - const isDisabled = isForwardApprove && blockedByV2Level + // Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match + // currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động." + const isDisabled = blockedByV2Level const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt' const title = isDisabled && evaluation.currentApproval - ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.` + ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới thao tác được (Duyệt / Trả lại / Từ chối).` : isForwardApprove ? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}` : undefined diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 399947f..05f4d9b 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -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. diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs index 9061759..a7f160b 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs @@ -334,6 +334,44 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests } } + // ============ UAT S22+1: V2 actor scope guard cho Reject ============ + + [Fact] + public async Task Reject_NonApprover_V2_Throws_ForbiddenException() + { + // UAT S22+1 — actor không phải approver Cấp hiện tại + V2 pin + non-admin + // → BE guard throw ForbiddenException. Mirror FE button disable logic. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "guard1"); + // SeedWorkflow Level 2 ApproverUserId = a2. Cấp hiện tại = Level 2. + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true); + var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-G-V2-001"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // Outsider gọi Reject — không phải a2 (current Level approver). + var outsider = await fix.CreateUserAsync( + "outsider-guard@test.local", "Outsider Guard", departmentId: null, + roles: new[] { AppRoles.CostControl }); + + var act = async () => await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TraLai, + actorUserId: outsider.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "outsider thử forge reject", + returnMode: WorkflowReturnMode.Drafter, + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Không phải lượt bạn*Trả lại / Từ chối*"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate phase"); + } + } + [Fact] public async Task SkipToFinal_AdminBypass_Succeeds() {