[CLAUDE] PurchaseEvaluation · Tests: phiếu nháp riêng tư người tạo (chặn leak role + V2-approver)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m56s

Màn hình CEO ở "chỗ thao tác" hiện bản nháp / phiếu chưa xong của NGƯỜI KHÁC (anh Kiệt
FDC). Phiếu nháp (DangSoanThao) lộ qua 2 đường: cond #2 role-eligible (Drafter/DeptManager
→ DangSoanThao, mọi Drafter thấy nháp nhau) + cond #3 V2-approver (workflow pin lúc TẠO
phiếu nên approver như CEO thấy mọi nháp trước khi gửi duyệt = leak chính).

Luật mới: nháp = RIÊNG TƯ người tạo (DrafterUserId) + Admin thấy hết. 4 chỗ:
- GetEligiblePhases: bỏ DangSoanThao khỏi role-eligible (cond #2).
- Inbox (mảng admin): bỏ DangSoanThao — nháp khỏi Inbox hẳn kể cả admin (anh Kiệt chốt).
- List query cond #3: guard != DangSoanThao (approver không thấy nháp).
- Detail authz: guard != DangSoanThao cho V2-approver (không mở nháp người khác bằng link).
Phiếu ĐÃ gửi duyệt giữ nguyên (approver vẫn thấy). Phần admin-screen tinh chỉnh sâu để sau.

+PeDraftVisibilityTests (2 test, test-before security): owner thấy nháp mình · drafter khác
không · CEO/V2-approver không · admin thấy hết · phiếu đã gửi vẫn hiện (no regression).
Full suite 419→421 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-25 14:39:26 +07:00
parent f7bd97a83d
commit a71753bde8
2 changed files with 139 additions and 4 deletions

View File

@ -582,10 +582,15 @@ public class ListPurchaseEvaluationsQueryHandler(
.Select(l => l.Step!.ApprovalWorkflowId)
.Distinct()
.ToListAsync(ct);
// [S89] Nháp = owner-only (cond1 DrafterUserId). cond2 role: eligiblePhases đã bỏ
// DangSoanThao. cond3 V2-approver: guard !=DangSoanThao → approver KHÔNG thấy phiếu
// còn nháp (workflow đã pin lúc tạo nhưng chưa gửi duyệt), chỉ thấy khi đã gửi.
q = q.Where(x =>
x.e.DrafterUserId == userId
|| eligiblePhases.Contains(x.e.Phase)
|| (x.e.ApprovalWorkflowId != null && userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
|| (x.e.Phase != PurchaseEvaluationPhase.DangSoanThao
&& x.e.ApprovalWorkflowId != null
&& userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
}
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
@ -642,7 +647,11 @@ public class ListPurchaseEvaluationsQueryHandler(
if (userRoles.Any(r => required.Contains(r)))
foreach (var p in toAdd) phases.Add(p);
}
AddIfAny([AppRoles.Drafter, AppRoles.DeptManager], PurchaseEvaluationPhase.DangSoanThao);
// [S89 anh Kiệt FDC] DangSoanThao KHÔNG còn là role-eligible phase: phiếu NHÁP =
// RIÊNG TƯ người tạo (thấy qua DrafterUserId ownership + Admin), KHÔNG lộ qua role
// Drafter/DeptManager. Trước đây map DangSoanThao → mọi Drafter/DeptManager thấy
// nháp của nhau (rối — màn hình CEO đầy nháp người khác). Approver chỉ thấy phiếu khi
// ĐÃ gửi duyệt (V2-approver cond cũng guard !=DangSoanThao ở list + detail authz).
AddIfAny([AppRoles.Procurement], PurchaseEvaluationPhase.ChoPurchasing);
AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
@ -672,7 +681,8 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
var isAdmin = userRoles.Contains(AppRoles.Admin);
var eligiblePhases = isAdmin
? [
PurchaseEvaluationPhase.DangSoanThao,
// [S89] Nháp (DangSoanThao) BỎ khỏi Inbox kể cả Admin: Inbox = "việc chờ duyệt",
// nháp xem ở danh sách/workspace (anh Kiệt: bỏ nháp khỏi Inbox hẳn).
PurchaseEvaluationPhase.ChoPurchasing,
PurchaseEvaluationPhase.ChoDuAn,
PurchaseEvaluationPhase.ChoCCM,
@ -808,7 +818,11 @@ public class GetPurchaseEvaluationQueryHandler(
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
&& l.ApproverUserId == uidForCheck, ct);
}
if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isV2Approver)
// [S89] Nháp = RIÊNG TƯ: V2-approver KHÔNG mở được phiếu CÒN nháp (chỉ thấy khi đã
// gửi duyệt). eligiblePhases đã bỏ DangSoanThao → cond role cũng loại; chỉ Drafter
// (+ Admin bypass trên) mở được nháp.
var isDraftPhase = e.Phase == PurchaseEvaluationPhase.DangSoanThao;
if (!isDrafter && !eligiblePhases.Contains(e.Phase) && (isDraftPhase || !isV2Approver))
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
}