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); + } +}