[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

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