[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
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:
@ -49,9 +49,10 @@ export function PeWorkflowPanel({
|
|||||||
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||||
|
|
||||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
// 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"
|
// thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền
|
||||||
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
|
// mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor
|
||||||
// hành động này — Approver có thể reject bất cứ lúc nào trong phiên).
|
// không match currentLevel.ApproverUserId. BE mirror guard trong
|
||||||
|
// EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject).
|
||||||
// Admin bypass.
|
// Admin bypass.
|
||||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
const actorInV2Level = isAdmin
|
const actorInV2Level = isAdmin
|
||||||
@ -239,11 +240,12 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
||||||
const isForwardApprove = !isSendBack && !isCancel
|
const isForwardApprove = !isSendBack && !isCancel
|
||||||
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại
|
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
||||||
const isDisabled = isForwardApprove && blockedByV2Level
|
// 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 label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
|
||||||
const title = isDisabled && evaluation.currentApproval
|
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
|
: isForwardApprove
|
||||||
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
|
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@ -51,7 +51,9 @@ export function PeWorkflowPanel({
|
|||||||
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||||
|
|
||||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
// 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 v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
const actorInV2Level = isAdmin
|
const actorInV2Level = isAdmin
|
||||||
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
|
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
@ -235,11 +237,12 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
||||||
const isForwardApprove = !isSendBack && !isCancel
|
const isForwardApprove = !isSendBack && !isCancel
|
||||||
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại
|
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
||||||
const isDisabled = isForwardApprove && blockedByV2Level
|
// 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 label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
|
||||||
const title = isDisabled && evaluation.currentApproval
|
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
|
: isForwardApprove
|
||||||
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
|
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@ -74,6 +74,12 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
|
// ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) =====
|
||||||
if (decision == ApprovalDecision.Reject)
|
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)
|
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
{
|
{
|
||||||
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
// 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ợ.");
|
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 =====
|
// ===== F1 (Mig 28 — S21 t4) — Apply Return Mode =====
|
||||||
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
|
// Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet
|
||||||
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
|
// (peer review chain). Mode Drafter set Phase=TraLai như S17.
|
||||||
|
|||||||
@ -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<ForbiddenException>()
|
||||||
|
.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]
|
[Fact]
|
||||||
public async Task SkipToFinal_AdminBypass_Succeeds()
|
public async Task SkipToFinal_AdminBypass_Succeeds()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user