From a71753bde81a9a49ba7bbbc1e1b7e5a299446e72 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 25 Jun 2026 14:39:26 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation=20=C2=B7=20Tests:?= =?UTF-8?q?=20phi=E1=BA=BFu=20nh=C3=A1p=20ri=C3=AAng=20t=C6=B0=20ng=C6=B0?= =?UTF-8?q?=E1=BB=9Di=20t=E1=BA=A1o=20(ch=E1=BA=B7n=20leak=20role=20+=20V2?= =?UTF-8?q?-approver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PurchaseEvaluationFeatures.cs | 22 +++- .../Application/PeDraftVisibilityTests.cs | 121 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/PeDraftVisibilityTests.cs diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index d3a134d..1774bfa 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -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."); } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeDraftVisibilityTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeDraftVisibilityTests.cs new file mode 100644 index 0000000..08770c4 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeDraftVisibilityTests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [S89 anh Kiệt FDC] Phiếu NHÁP (DangSoanThao) = RIÊNG TƯ người tạo (+ Admin thấy hết). +// Trước đây "chỗ thao tác" màn hình CEO hiện nháp của người khác qua 2 đường: +// cond #2 role-eligible (Drafter/DeptManager → DangSoanThao) — mọi Drafter thấy nháp nhau; +// cond #3 V2-approver — CEO là approver trên workflow → thấy MỌI nháp (workflow pin lúc +// TẠO phiếu, chưa gửi duyệt) = leak chính trên màn hình CEO. +// Fix: nháp chỉ thấy/mở qua DrafterUserId ownership (+ Admin bypass). Phiếu ĐÃ gửi duyệt +// giữ nguyên (approver vẫn thấy). Test-before security guard (rules §7 — critical authz). +// Dùng IdentityFixture: ApproverUserId/DrafterUserId là FK → cần User rows thật. +public class PeDraftVisibilityTests +{ + private static (Guid projectId, Guid wfId) SeedProjectAndWorkflow( + TestApplicationDbContext db, Guid approverUserId) + { + var project = new Project { Id = Guid.NewGuid(), Code = "VISP", Name = "Dự án Visibility" }; + db.Projects.Add(project); + + var wf = new ApprovalWorkflow + { + Code = "QT-VIS-V2", + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = "QT visibility test", + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" }; + step.Levels.Add(new ApprovalWorkflowLevel + { + ApprovalWorkflowStepId = step.Id, + Order = 1, + Name = "Cấp 1", + ApproverUserId = approverUserId, // CEO-like: approver trên workflow này + }); + wf.Steps.Add(step); + db.ApprovalWorkflows.Add(wf); + return (project.Id, wf.Id); + } + + private static Guid SeedPe( + TestApplicationDbContext db, Guid projectId, Guid wfId, Guid drafterId, + PurchaseEvaluationPhase phase, string code) + { + var id = Guid.NewGuid(); + db.PurchaseEvaluations.Add(new PurchaseEvaluation + { + Id = id, + MaPhieu = code, + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + TenGoiThau = $"Gói {code}", + ProjectId = projectId, + DrafterUserId = drafterId, + ApprovalWorkflowId = wfId, // workflow pin lúc tạo (kể cả nháp) — Features.cs:149 + }); + return id; + } + + private static async Task> ListVisibleIds( + TestApplicationDbContext db, Guid? userId, params string[] roles) + { + var cu = new TestCurrentUser { UserId = userId, Roles = roles }; + var handler = new ListPurchaseEvaluationsQueryHandler(db, cu); + var res = await handler.Handle( + new ListPurchaseEvaluationsQuery { PageSize = 100 }, CancellationToken.None); + return res.Items.Select(x => x.Id).ToList(); + } + + [Fact] + public async Task Draft_VisibleToOwnerOnly_NotOtherDrafter_NotApprover_AdminSeesAll() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var drafterA = await fix.CreateUserAsync("draftera@test.local", "Drafter A", null, new[] { AppRoles.Drafter }); + var drafterB = await fix.CreateUserAsync("drafterb@test.local", "Drafter B", null, new[] { AppRoles.Drafter }); + var ceo = await fix.CreateUserAsync("ceo@test.local", "CEO Director", null, new[] { AppRoles.Director }); + + var (projectId, wfId) = SeedProjectAndWorkflow(db, approverUserId: ceo.Id); + var draft = SeedPe(db, projectId, wfId, drafterA.Id, + PurchaseEvaluationPhase.DangSoanThao, "PE-DRAFT"); + await db.SaveChangesAsync(CancellationToken.None); + + // Owner thấy nháp của CHÍNH MÌNH (cond #1 ownership). + (await ListVisibleIds(db, drafterA.Id, AppRoles.Drafter)).Should().Contain(draft); + // Drafter KHÁC (cùng role) KHÔNG thấy nháp của A (cond #2 leak ĐÃ vá). + (await ListVisibleIds(db, drafterB.Id, AppRoles.Drafter)).Should().NotContain(draft); + // V2-approver (CEO trên workflow) KHÔNG thấy nháp (cond #3 leak ĐÃ vá — đúng màn hình CEO). + (await ListVisibleIds(db, ceo.Id, AppRoles.Director)).Should().NotContain(draft); + // Admin thấy hết (anh Kiệt: "full admin với thấy hết"). + (await ListVisibleIds(db, Guid.NewGuid(), AppRoles.Admin)).Should().Contain(draft); + } + + [Fact] + public async Task SubmittedPhieu_StillVisibleToApprover_NotRegressed() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var drafterA = await fix.CreateUserAsync("draftera2@test.local", "Drafter A2", null, new[] { AppRoles.Drafter }); + var ceo = await fix.CreateUserAsync("ceo2@test.local", "CEO2 Director", null, new[] { AppRoles.Director }); + + var (projectId, wfId) = SeedProjectAndWorkflow(db, approverUserId: ceo.Id); + // Phiếu ĐÃ gửi duyệt (ChoDuyet) — approver PHẢI vẫn thấy (regression guard: chỉ + // chặn lúc CÒN nháp, không chặn sau khi đã gửi duyệt). + var submitted = SeedPe(db, projectId, wfId, drafterA.Id, + PurchaseEvaluationPhase.ChoDuyet, "PE-SUB"); + await db.SaveChangesAsync(CancellationToken.None); + + (await ListVisibleIds(db, ceo.Id, AppRoles.Director)).Should().Contain(submitted); + } +}