[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
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:
@ -582,10 +582,15 @@ public class ListPurchaseEvaluationsQueryHandler(
|
|||||||
.Select(l => l.Step!.ApprovalWorkflowId)
|
.Select(l => l.Step!.ApprovalWorkflowId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToListAsync(ct);
|
.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 =>
|
q = q.Where(x =>
|
||||||
x.e.DrafterUserId == userId
|
x.e.DrafterUserId == userId
|
||||||
|| eligiblePhases.Contains(x.e.Phase)
|
|| 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);
|
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)))
|
if (userRoles.Any(r => required.Contains(r)))
|
||||||
foreach (var p in toAdd) phases.Add(p);
|
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.Procurement], PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
|
AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
|
||||||
AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
|
AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
|
||||||
@ -672,7 +681,8 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
|||||||
var isAdmin = userRoles.Contains(AppRoles.Admin);
|
var isAdmin = userRoles.Contains(AppRoles.Admin);
|
||||||
var eligiblePhases = isAdmin
|
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.ChoPurchasing,
|
||||||
PurchaseEvaluationPhase.ChoDuAn,
|
PurchaseEvaluationPhase.ChoDuAn,
|
||||||
PurchaseEvaluationPhase.ChoCCM,
|
PurchaseEvaluationPhase.ChoCCM,
|
||||||
@ -808,7 +818,11 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
|
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
|
||||||
&& l.ApproverUserId == uidForCheck, ct);
|
&& 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.");
|
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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<List<Guid>> 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<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user