[CLAUDE] App+Infra+Tests: W6 KHKK — duong ong HD V2 (view per-HD 3-site + inbox V2+ADMIN + guard trinh +PMH)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m52s

- View: vế isV2Approver per-HĐ ở CẢ 3 call-site GetEligiblePhases (:301/:381/:486) — không role-blanket, nháp-riêng-tư S89 giữ, deleted-view cùng vị ngữ
- Inbox: ghép v2InboxIds precompute (mirror PE ResolveV2InboxIdsAsync) + nhánh ADMIN phase-list tường minh +ChoDuyet (DR-4/F-B13) — HĐ ChoDuyet vào inbox cả approver lẫn admin
- Guard trình CWS:73-78: Drafter|DeptManager|người-tạo|Procurement + chặn null==null lọt guard (actorUserId is Guid); Reject :49-66 + vùng W3 :217-394 0-diff (O-A giữ)
- tests +7 (587/0): 5 PIN + admin-inbox DR-4 + Submit 4-vế/null-trap
- Reviewer: Trục 1-3 vai PASS 0-critical + 4-7 lead — 2 ghi-nhận owner: workflow-blanket-by-design độ rộng + Reject pre-existing AMPLIFIED (bán kính nổ tăng khi view mở)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 23:27:19 +07:00
parent d03d097886
commit 761d8183f3
3 changed files with 811 additions and 6 deletions

View File

@ -299,7 +299,19 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
{ {
var userId = currentUser.UserId; var userId = currentUser.UserId;
var eligiblePhases = GetEligiblePhases(currentUser.Roles); var eligiblePhases = GetEligiblePhases(currentUser.Roles);
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase)); // [W6 S161] +2 vế cho đường ống V2 (spec §②-3). Vế V2 là THEO-TỪNG-HĐ
// (workflow pin của CHÍNH HĐ đó), KHÔNG phải role-blanket — xem chú thích
// khối helper dưới `GetEligiblePhases`.
var myWorkflowIds = await ResolveUserApprovalWorkflowIdsAsync(db, userId, ct);
var isHardCopyActor = HasHardCopyActorRole(currentUser.Roles);
q = q.Where(x => x.c.DrafterUserId == userId
|| eligiblePhases.Contains(x.c.Phase)
// (a) có chân duyệt trong workflow ĐÃ PIN của HĐ này + HĐ đã rời Nháp (S89:
// nháp = riêng tư người soạn, approver chỉ thấy khi đã gửi duyệt).
|| (x.c.Phase != ContractPhase.DangSoanThao
&& myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
// (b) vai bản-cứng khâu ký/đóng dấu → HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
|| (isHardCopyActor && x.c.Phase == ContractPhase.DaPhatHanh));
} }
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase); if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
@ -349,6 +361,38 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu
AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau); AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau);
return phases.ToList(); return phases.ToList();
} }
// ===== [W6 S161] Vế V2 per-HĐ — CỐ Ý TÁCH KHỎI `GetEligiblePhases` =====
// 🔴 KHÔNG được nhét `ChoDuyet`/`DaPhatHanh` vào `GetEligiblePhases`: hàm đó chỉ nhận
// ROLE (không biết đang xét HĐ nào) ⇒ thêm phase vào đó = MỌI user mang role đó thấy
// MỌI HĐ ở phase đó. Nặng hơn: nó là hàm DÙNG CHUNG với `:381` ListDeleted ⇒ HĐ
// `ChoDuyet` đã XÓA MỀM sẽ rò sang màn "Đã xóa" (acceptance §③-B cấm đúng ca này).
// Vì vậy 2 vế dưới chỉ gắn ở 2 call-site `:301` List + Detail (`GetContractQueryHandler`),
// KHÔNG gắn ở ListDeleted — quyết định có chủ đích, xem chú thích tại chỗ `:381`.
/// Vai bản-cứng khâu ký / đóng dấu / phát hành (spec §②-3 — actor b.19/20/21): được xem
/// HĐ **đã phát hành** (`DaPhatHanh`) dù không có chân trong workflow V2 nào. Cho GĐ4/W7.
internal static readonly string[] HardCopyActorRoles =
[AppRoles.Director, AppRoles.AuthorizedSigner, AppRoles.HrAdmin, AppRoles.CostControl];
internal static bool HasHardCopyActorRole(IReadOnlyList<string> userRoles)
=> userRoles.Any(r => HardCopyActorRoles.Contains(r));
/// Các workflow V2 mà `userId` có chân duyệt (BẤT KỲ Bước/Cấp) — bản-set cho query LIST,
/// mirror `ContractSigningPlanFeatures.cs:275-283`; bản 1-phiếu là `AnyAsync` kiểu PE
/// `PurchaseEvaluationFeatures.cs:889-891`.
/// 🔴 Trả `List&lt;Guid?&gt;` CÓ CHỦ ĐÍCH: cột `Contract.ApprovalWorkflowId` là `Guid?` ⇒
/// `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` dịch thẳng `IN (...)` không cần
/// `.Value` (NULL không bao giờ khớp `IN` ⇒ HĐ V1 legacy tự rớt khỏi vế này).
internal static async Task<List<Guid?>> ResolveUserApprovalWorkflowIdsAsync(
IApplicationDbContext db, Guid? userId, CancellationToken ct)
=> userId is null
? []
: await db.ApprovalWorkflowLevels.AsNoTracking()
.Where(l => l.ApproverUserId == userId.Value)
.Select(l => (Guid?)l.Step!.ApprovalWorkflowId)
.Distinct()
.ToListAsync(ct);
} }
// ========== LIST DELETED contracts — màn "Đã xóa" (chỉ-xem) ========== // ========== LIST DELETED contracts — màn "Đã xóa" (chỉ-xem) ==========
@ -379,6 +423,11 @@ public class ListDeletedContractsQueryHandler(IApplicationDbContext db, ICurrent
{ {
var userId = currentUser.UserId; var userId = currentUser.UserId;
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles); var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
// 🔴 [W6 S161] CALL-SITE THỨ 3 — CỐ Ý **KHÔNG** ghép 2 vế V2 (`myWorkflowIds` /
// `HardCopyActorRoles`) đã thêm ở List `:301` + Detail. Đây là màn "Đã xóa":
// acceptance W6 §③-B đòi HĐ `ChoDuyet` xóa mềm KHÔNG được rò sang user V2.
// Scope giữ NGUYÊN (Drafter eligible-phase-role) = 0 dòng thay đổi hành vi.
// Đụng vào đây là mở lỗ, không phải "làm cho đủ 3 chỗ".
q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase)); q = q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase));
} }
@ -429,6 +478,24 @@ public class GetMyInboxQueryHandler(
[ContractPhase.DangDongDau] = [AppRoles.HrAdmin], [ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
}; };
/// 🔴 [W6 S161 — DR-4] Phase Admin thấy trong Inbox, LIỆT KÊ TƯỜNG MINH (mirror
/// `PurchaseEvaluationFeatures.cs:731-742`). Trước W6 admin lấy `PhaseActorRoles.Keys`,
/// mà dict đó 100% phase legacy (0 key `ChoDuyet`) ⇒ ghép `v2InboxIds` xong admin VẪN
/// mù HĐ chờ duyệt. Danh sách này = 7 phase legacy CŨ **+ `ChoDuyet`** ⇒ THUẦN CỘNG
/// THÊM, không bỏ phase nào admin đang thấy (cố ý KHÔNG bê luật S89 "bỏ Nháp khỏi Inbox"
/// của PE sang: đó là gỡ hành-vi, ngoài phạm vi W6).
internal static readonly ContractPhase[] AdminInboxPhases =
[
ContractPhase.DangSoanThao,
ContractPhase.DangGopY,
ContractPhase.DangDamPhan,
ContractPhase.DangInKy,
ContractPhase.DangKiemTraCCM,
ContractPhase.DangTrinhKy,
ContractPhase.DangDongDau,
ContractPhase.ChoDuyet,
];
public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct) public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
{ {
if (!currentUser.IsAuthenticated) throw new UnauthorizedException(); if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
@ -438,18 +505,26 @@ public class GetMyInboxQueryHandler(
// Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc) // Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
var eligiblePhases = isAdmin var eligiblePhases = isAdmin
? PhaseActorRoles.Keys.ToList() ? AdminInboxPhases.ToList()
: PhaseActorRoles : PhaseActorRoles
.Where(kv => kv.Value.Any(r => userRoles.Contains(r))) .Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
.Select(kv => kv.Key) .Select(kv => kv.Key)
.ToList(); .ToList();
if (eligiblePhases.Count == 0) return []; // [W6 S161] V2-aware: HĐ pin V2 + Phase=ChoDuyet + actor là approver CẤP ĐANG CHỜ.
// Admin đi bằng nhánh PHASE (`ChoDuyet` ở trên) nên để rỗng — mirror PE `:746-748`.
var v2InboxIds = isAdmin
? new HashSet<Guid>()
: await ResolveV2InboxIdsAsync(currentUser.UserId, ct);
// 🔴 Điều kiện thoát sớm phải xét CẢ 2 nguồn: user chỉ có chân duyệt V2 mà không mang
// role legacy nào (eligiblePhases rỗng) vẫn PHẢI thấy hộp thư của mình.
if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];
var q = from c in db.Contracts.AsNoTracking() var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
where eligiblePhases.Contains(c.Phase) where eligiblePhases.Contains(c.Phase) || v2InboxIds.Contains(c.Id)
orderby c.SlaDeadline ?? DateTime.MaxValue orderby c.SlaDeadline ?? DateTime.MaxValue
select new ContractListItemDto( select new ContractListItemDto(
c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase, c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
@ -458,6 +533,48 @@ public class GetMyInboxQueryHandler(
return await q.Take(100).ToListAsync(ct); return await q.Take(100).ToListAsync(ct);
} }
/// Precompute ID các HĐ mà `userId` là approver của Cấp ĐANG CHỜ — mirror
/// `PurchaseEvaluationFeatures.cs:817-852` (và bản KHKK `ContractSigningPlanFeatures.cs:229-264`).
/// 🔴 In-memory join CÓ CHỦ ĐÍCH: `CurrentWorkflowStepIndex` là INDEX 0-based vào list Steps
/// đã sort theo `Order`, KHÔNG phải chính `Order` ⇒ không dịch thẳng sang SQL được.
/// So Cấp bằng GIÁ TRỊ `Level.Order` + lấy `Any(...)` ⇒ giữ nguyên OR-of-N cùng Cấp.
/// HĐ xóa mềm tự rớt: `ContractConfiguration.cs:44` có global filter `!IsDeleted`.
private async Task<HashSet<Guid>> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct)
{
if (userId is null) return new HashSet<Guid>();
var candidates = await (
from c in db.Contracts.AsNoTracking()
where c.Phase == ContractPhase.ChoDuyet
&& c.ApprovalWorkflowId != null
&& c.CurrentWorkflowStepIndex != null
&& c.CurrentApprovalLevelOrder != null
select new { c.Id, c.ApprovalWorkflowId, c.CurrentWorkflowStepIndex, c.CurrentApprovalLevelOrder }
).ToListAsync(ct);
if (candidates.Count == 0) return new HashSet<Guid>();
var wfIds = candidates.Select(c => c.ApprovalWorkflowId!.Value).Distinct().ToList();
var workflows = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => wfIds.Contains(w.Id))
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.ToDictionaryAsync(w => w.Id, ct);
var result = new HashSet<Guid>();
foreach (var c in candidates)
{
if (!workflows.TryGetValue(c.ApprovalWorkflowId!.Value, out var wf)) continue;
var steps = wf.Steps.OrderBy(s => s.Order).ToList();
var idx = c.CurrentWorkflowStepIndex!.Value;
if (idx < 0 || idx >= steps.Count) continue;
var match = steps[idx].Levels.Any(l =>
l.Order == c.CurrentApprovalLevelOrder!.Value && l.ApproverUserId == userId.Value);
if (match) result.Add(c.Id);
}
return result;
}
} }
// ========== GET detail ========== // ========== GET detail ==========
@ -485,7 +602,22 @@ public class GetContractQueryHandler(
var isDrafter = c.DrafterUserId == currentUser.UserId; var isDrafter = c.DrafterUserId == currentUser.UserId;
var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles); var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles);
var isEligibleByRole = eligiblePhases.Contains(c.Phase); var isEligibleByRole = eligiblePhases.Contains(c.Phase);
if (!isDrafter && !isEligibleByRole) // [W6 S161] Vế V2 THEO-TỪNG-HĐ — mirror PE `PurchaseEvaluationFeatures.cs:886-898`:
// actor có chân `ApproverUserId` ở BẤT KỲ Cấp nào của workflow ĐÃ PIN CỦA HĐ NÀY.
// Hỏi theo `c.ApprovalWorkflowId` (không phải theo role) ⇒ user ngoài workflow vẫn 403.
var isV2Approver = false;
if (c.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck)
{
isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking()
.AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck
&& l.ApproverUserId == uidForCheck, ct);
}
// [S89 mirror PE] Nháp = RIÊNG TƯ người soạn: V2-approver KHÔNG mở được HĐ còn Nháp.
var isDraftPhase = c.Phase == ContractPhase.DangSoanThao;
// Vai bản-cứng ký/đóng dấu → xem được HĐ ĐÃ PHÁT HÀNH (GĐ4/W7).
var isHardCopyActor = ListContractsQueryHandler.HasHardCopyActorRole(currentUser.Roles)
&& c.Phase == ContractPhase.DaPhatHanh;
if (!isDrafter && !isEligibleByRole && !isHardCopyActor && (isDraftPhase || !isV2Approver))
throw new ForbiddenException("Bạn không có quyền xem HĐ này."); throw new ForbiddenException("Bạn không có quyền xem HĐ này.");
} }

View File

@ -70,9 +70,22 @@ public class ContractWorkflowService(
if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai) if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai)
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem))) && (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
{ {
// [W6 S161] Nới vai được TRÌNH: `Drafter DeptManager người-tạo Procurement`.
// Lỗ đo 07-29: nhân sự Phòng Mua hàng (role `Procurement`) bị 403 khi trình HĐ do
// chính mình lập. Vẫn giữ 403 cho role NGOÀI 4 vế này.
// 🔴 `actorUserId is Guid` là BẮT BUỘC, không được viết thẳng
// `contract.CreatedBy == actorUserId`: cả hai đều `Guid?` ⇒ HĐ seed/legacy có
// `CreatedBy == null` gặp actor null sẽ ra `null == null` = TRUE và lọt guard.
// Xét cả `DrafterUserId` vì đó là field handler tạo HĐ gán tường minh
// (`ContractFeatures.cs:96`), còn `CreatedBy` do `AuditingInterceptor.cs:45` gán —
// HĐ nạp từ seed có thể trống ô sau.
var isCreator = actorUserId is Guid actorId
&& (contract.CreatedBy == actorId || contract.DrafterUserId == actorId);
if (!isAdmin && !isSystem if (!isAdmin && !isSystem
&& !actorRoles.Contains(AppRoles.Drafter) && !actorRoles.Contains(AppRoles.Drafter)
&& !actorRoles.Contains(AppRoles.DeptManager)) && !actorRoles.Contains(AppRoles.DeptManager)
&& !actorRoles.Contains(AppRoles.Procurement)
&& !isCreator)
{ {
throw new ForbiddenException( throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ."); $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");

View File

@ -0,0 +1,660 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W6 ĐỢT-2 — S161 2026-07-29] Đường ống HĐ V2: xem / hộp thư / vai được trình.
// Spec: `runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w6-duong-ong-hd-v2-29-07-2026.md` §③-B.
// Timing = **test-after** (đợt-1 đã land, build sạch, 580 xanh) ⇒ 7 test này phải XANH ngay.
// Đỏ ở đây = hồi quy thật, KHÔNG được sửa test cho vừa.
//
// 5 tên đầu PIN theo spec (`:27`) — thiếu/đổi 1 chữ là TRƯỢT acceptance. 2 cái cuối là bonus
// (DR-4 admin-inbox + bẫy `CreatedBy` null của guard trình).
//
// ── Đo cái gì (map 1:1 spec §③-B) ────────────────────────────────────────────────
// T1 hộp thư: approver Cấp ĐANG CHỜ thấy HĐ ChoDuyet T2 (ÂM) cấp/bước khác KHÔNG thấy
// T3 xem: approver V2 không-phải-người-soạn mở được T4 LIST: vai bản-cứng thấy HĐ đã phát hành
// (+ khối kiểm hở XÓA MỀM, §③-B `:31`)
// T5 (ÂM) người ngoài quy trình, role CỤ THỂ → 403 T6 bonus: admin thấy ChoDuyet (DR-4)
// T7 bonus: guard trình nới đúng vai + bịt bẫy actor-null
//
// ── 3 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ─────────────────────────
//
// (1) `Step.Order = 5 / 15`, KHÔNG phải 1 / 2. `CurrentWorkflowStepIndex` là **INDEX vào list
// đã sort theo Order** chứ không phải giá trị `Order` (`ContractFeatures.cs:569-571`
// `steps[idx]`). Để Order = 1/2 thì index 0/1 và Order 1/2 lệch nhau đúng 1 — một cài đặt
// sai kiểu `First(s => s.Order == idx)` vẫn có thể vô tình đúng ở vài ca. Order 5/15 làm
// hai đại lượng KHÔNG THỂ trùng nhau do may mắn.
//
// (2) `TestApplicationDbContext` **KHÔNG wire** `AuditingInterceptor` ⇒ (a) `CreatedBy` của HĐ
// seed là **null tự nhiên** — đúng cấu hình để đo bẫy `null == null` ở T7; (b) `Remove()`
// là xoá **CỨNG**, nên khối xoá-mềm của T4 set thẳng `IsDeleted = true` chứ KHÔNG gọi
// `Remove` (bài S155: đo "xoá mềm" trên xoá cứng = đo nhầm cơ chế, vì rào ở đây là
// global-filter che dòng, không phải dòng biến mất).
//
// (3) Mỗi test dựng `IdentityFixture` RIÊNG (SQLite :memory: riêng) ⇒ HĐ của test này không
// lọt vào hộp thư/danh sách của test kia. Các assert dạng `BeEquivalentTo(<tập đủ>)` chỉ
// có nghĩa khi thế giới đóng.
public class ContractV2PipelineTests
{
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Seed helpers
// ===================================================================
private static TestCurrentUser As(User u, params string[] roles)
=> new(u.Id, u.FullName, u.Email, roles);
private static async Task<(Supplier sup, Project proj)> SeedMasterAsync(TestApplicationDbContext db)
{
var sup = new Supplier
{
Id = Guid.NewGuid(),
Code = "BTBM",
Name = "NCC Bê tông Bình Minh",
Type = SupplierType.NhaThauPhu,
};
var proj = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
db.Suppliers.Add(sup);
db.Projects.Add(proj);
await db.SaveChangesAsync(CancellationToken.None);
return (sup, proj);
}
/// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1** (nhiều NV cùng Cấp = OR-of-N).
/// `Step.Order` cố ý chạy 5 / 15 / 25 — xem ghi chú hạ tầng (1).
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db, string code, Guid[][][] steps)
{
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = $"Quy trình duyệt HĐ {code}",
ApplicableType = ApprovalWorkflowApplicableType.Contract,
IsActive = true,
IsUserSelectable = true,
};
db.ApprovalWorkflows.Add(wf);
for (var s = 0; s < steps.Length; s++)
{
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 5 + (s * 10), // 5 / 15 / 25 ≠ index 0 / 1 / 2
Name = $"Bước {s + 1}",
DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
};
db.ApprovalWorkflowSteps.Add(step);
for (var l = 0; l < steps[s].Length; l++)
{
foreach (var approverId in steps[s][l])
{
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = step.Id,
Order = l + 1, // Cấp 1-based
Name = $"Cấp {l + 1}",
ApproverUserId = approverId,
});
}
}
}
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static async Task<Contract> AddContractAsync(
TestApplicationDbContext db,
Supplier sup,
Project proj,
Guid drafterId,
ContractPhase phase,
string tenHopDong,
Guid? workflowId = null,
int? stepIndex = null,
int? levelOrder = null)
{
var c = new Contract
{
Id = Guid.NewGuid(),
Type = ContractType.HopDongThauPhu,
Phase = phase,
SupplierId = sup.Id,
ProjectId = proj.Id,
DrafterUserId = drafterId,
TenHopDong = tenHopDong,
GiaTri = 100_000_000m,
ApprovalWorkflowId = workflowId,
CurrentWorkflowStepIndex = stepIndex,
CurrentApprovalLevelOrder = levelOrder,
};
db.Contracts.Add(c);
await db.SaveChangesAsync(CancellationToken.None);
return c;
}
// ===================================================================
// Act helpers — gọi HANDLER THẬT, không mock
// ===================================================================
private static Task<List<ContractListItemDto>> InboxAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None);
private static Task<PagedResult<ContractListItemDto>> ListAsync(
TestApplicationDbContext db, TestCurrentUser actor, ContractPhase? phase = null)
=> new ListContractsQueryHandler(db, actor)
.Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None);
private static Task<PagedResult<ContractListItemDto>> ListDeletedAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new ListDeletedContractsQueryHandler(db, actor)
.Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None);
private static Task<ContractDetailDto> ViewAsync(
TestApplicationDbContext db, UserManager<User> um, TestCurrentUser actor, Guid contractId)
=> new GetContractQueryHandler(db, um, actor).Handle(new GetContractQuery(contractId), CancellationToken.None);
private static async Task SoftDeleteAsync(TestApplicationDbContext db, Contract c)
{
// KHÔNG dùng `db.Contracts.Remove(c)`: fixture không wire `AuditingInterceptor` nên
// `Remove` = xoá CỨNG (bài S155) ⇒ sẽ đo nhầm cơ chế. Rào thật là global query filter
// `!IsDeleted` (`ContractConfiguration.cs:44`) + `IgnoreQueryFilters().Where(IsDeleted)`
// ở màn "Đã xóa" — muốn đo nó thì phải để DÒNG CÒN ĐÓ và chỉ bật cờ.
c.IsDeleted = true;
c.DeletedAt = Clock2026;
await db.SaveChangesAsync(CancellationToken.None);
}
// ===================================================================
// (T1) Hộp thư — approver của Cấp ĐANG CHỜ thấy HĐ `ChoDuyet`. Spec §③-B `:27` tên #1.
//
// Trước W6 hộp thư 100% chạy bằng `PhaseActorRoles` (`ContractFeatures.cs:470-479`) — dict
// đó KHÔNG có key `ChoDuyet` ⇒ mọi HĐ V2 đang chờ duyệt vô hình với đúng người phải duyệt.
//
// 🔴 Approver A cố ý mang **0 role**: khi đó `eligiblePhases` rỗng, nên đường DUY NHẤT để A
// thấy HĐ là `v2InboxIds`. Điều này đồng thời ghim luôn sửa đổi ở `:520-522` — điều kiện
// thoát sớm phải xét CẢ HAI nguồn; giữ nguyên `if (eligiblePhases.Count == 0) return [];`
// thì A nhận hộp thư rỗng dù `v2InboxIds` có phiếu (một cái return sớm giết cả tính năng).
// Approver B mang role thật (`Procurement`) ⇒ chứng vế OR mới không đè vế legacy.
// ===================================================================
[Fact]
public async Task Inbox_PendingLevelApprover_SeesChoDuyetContract()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t1-drafter@test.local", "BCH soạn HĐ", null, [AppRoles.Drafter]);
var a = await fix.CreateUserAsync("t1-a@test.local", "NV Cung ứng A", null, []);
var b = await fix.CreateUserAsync("t1-b@test.local", "NV Cung ứng B", null, [AppRoles.Procurement]);
var c2 = await fix.CreateUserAsync("t1-c@test.local", "Trưởng phòng C", null, []);
// 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
var wf = await SeedWorkflowAsync(db, "QT-CT-T1", [[[a.Id, b.Id], [c2.Id]]]);
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ đang chờ Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
"HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);
// A: 0 role ⇒ chỉ có thể thấy qua nhánh V2 (xem ghi chú đầu test).
var inboxA = await InboxAsync(db, As(a));
inboxA.Select(i => i.Id).Should().BeEquivalentTo(new[] { pending.Id },
"approver của Cấp ĐANG CHỜ phải thấy HĐ trong hộp thư — trước W6 dict `PhaseActorRoles` "
+ "không có key `ChoDuyet` nên hộp thư rỗng và HĐ V2 treo vô thời hạn");
inboxA.Single().Phase.Should().Be(ContractPhase.ChoDuyet);
// B cùng Cấp 1 (OR-of-N) + có role thật ⇒ cũng phải thấy.
(await InboxAsync(db, As(b, AppRoles.Procurement))).Select(i => i.Id)
.Should().BeEquivalentTo(new[] { pending.Id },
"OR-of-N: người thứ hai cùng Cấp cũng thấy; chỉ 1 trong 2 thấy = match cứng theo NV đầu danh sách");
// Người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt'.
// (Drafter có role `Drafter` ⇒ `eligiblePhases` = {DangSoanThao, DangDamPhan, DangInKy}
// ⇒ HĐ Nháp của chính mình VẪN nằm trong hộp thư theo luật legacy — W6 cố ý KHÔNG gỡ.)
var inboxDrafter = await InboxAsync(db, As(drafter, AppRoles.Drafter));
inboxDrafter.Select(i => i.Id).Should().NotContain(pending.Id,
"người soạn không có chân duyệt ⇒ HĐ ChoDuyet không phải việc của họ");
inboxDrafter.Select(i => i.Id).Should().Contain(draft.Id,
"vế legacy (`PhaseActorRoles[DangSoanThao]`) phải còn nguyên — W6 là THUẦN CỘNG THÊM");
// HĐ Nháp không lọt hộp thư approver (`ResolveV2InboxIdsAsync` lọc `Phase == ChoDuyet`).
inboxA.Select(i => i.Id).Should().NotContain(draft.Id,
"HĐ chưa trình thì chưa chờ ai duyệt — lọt vào đây là approver đọc được bản nháp riêng tư");
}
// ===================================================================
// (T2) ÂM — Cấp khác / Bước khác KHÔNG thấy. Spec §③-B `:27` tên #2.
//
// Đây là lưới chặn cách "sửa cho xong" nguy hiểm nhất: precompute id-set theo **workflow**
// thay vì theo **con-trỏ** (tức ai có chân trong quy trình cũng thấy mọi HĐ dùng quy trình
// đó). Nó làm T1 xanh y hệt, chỉ vỡ ở đây.
//
// 🔴 Mỗi ca ÂM đều đi kèm CHỨNG NHÂN DƯƠNG của cùng người đó (C thấy HĐ khi con-trỏ chỉ
// Cấp 2; D thấy HĐ ở Bước 2). Thiếu vế này thì một `ResolveV2InboxIdsAsync` hỏng-luôn
// (trả rỗng mọi lúc) cũng cho test xanh — đúng kiểu vacuous-pass.
// ===================================================================
[Fact]
public async Task Inbox_OtherLevel_DoesNotSeeContract()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t2-drafter@test.local", "BCH soạn HĐ", null, []);
var a = await fix.CreateUserAsync("t2-a@test.local", "Bước 1 Cấp 1", null, []);
var c2 = await fix.CreateUserAsync("t2-c@test.local", "Bước 1 Cấp 2", null, []);
var d = await fix.CreateUserAsync("t2-d@test.local", "Bước 2 Cấp 1", null, []);
var outsider = await fix.CreateUserAsync("t2-out@test.local", "Ngoài quy trình", null, []);
// Bước 1 (Order 5): Cấp 1 = {A}, Cấp 2 = {C} · Bước 2 (Order 15): Cấp 1 = {D}.
var wf = await SeedWorkflowAsync(db, "QT-CT-T2", [[[a.Id], [c2.Id]], [[d.Id]]]);
var atStep0Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ chờ Bước 1 Cấp 1", wf.Id, stepIndex: 0, levelOrder: 1);
var atStep1Level1 = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ chờ Bước 2 Cấp 1", wf.Id, stepIndex: 1, levelOrder: 1);
// ---- ca ÂM 1: C có chân trong CHÍNH quy trình đó nhưng Ở CẤP KHÁC.
(await InboxAsync(db, As(c2))).Should().BeEmpty(
"Cấp 2 chưa tới lượt ⇒ hộp thư rỗng; thấy = duyệt vượt lượt ngay từ hộp thư");
// ---- ca ÂM 2: D ở BƯỚC KHÁC — chỉ thấy HĐ đang đứng ở bước của mình.
(await InboxAsync(db, As(d))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep1Level1.Id },
"D chỉ thấy HĐ có con-trỏ Bước = index 1; thấy cả HĐ Bước 1 nghĩa là id-set gom theo "
+ "QUY TRÌNH chứ không theo CON-TRỎ");
// A đối xứng: chỉ thấy HĐ Bước 1.
(await InboxAsync(db, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
"`CurrentWorkflowStepIndex` là INDEX vào list sort theo Order (5/15), KHÔNG phải Order — "
+ "đọc nhầm semantic thì A nhận HĐ của D hoặc không nhận gì");
(await InboxAsync(db, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc ở đây");
// ---- CHỨNG NHÂN DƯƠNG: đẩy con-trỏ sang Cấp 2 ⇒ C thấy, A hết thấy.
// Cùng một HĐ, cùng một người, đổi ĐÚNG MỘT biến (con-trỏ Cấp).
atStep0Level1.CurrentApprovalLevelOrder = 2;
await db.SaveChangesAsync(CancellationToken.None);
(await InboxAsync(db, As(c2))).Select(i => i.Id).Should().BeEquivalentTo(new[] { atStep0Level1.Id },
"chuyển con-trỏ sang Cấp 2 thì C PHẢI thấy — nếu C vẫn rỗng thì ca ÂM ở trên xanh vì "
+ "hộp thư hỏng-luôn, không phải vì lọc đúng cấp");
(await InboxAsync(db, As(a))).Should().BeEmpty(
"và A phải mất quyền tương ứng — hộp thư đi theo con-trỏ ở CẢ HAI CHIỀU");
}
// ===================================================================
// (T3) Xem — approver V2 KHÔNG phải người soạn mở được HĐ `ChoDuyet`. Spec §③-B `:27` tên #3.
//
// 3 vế chống "mở toang" (risk C của spec `:33`), mỗi vế là một cách hỏng khác nhau:
// • cross-workflow: approver của quy trình KHÁC vẫn 403 ⇒ điều kiện là THEO-TỪNG-HĐ
// (hỏi theo `c.ApprovalWorkflowId`), không phải theo role/theo "là approver ở đâu đó".
// • Nháp: chính approver đó vẫn 403 khi HĐ chưa trình (luật S89, `ContractFeatures.cs:615-616`).
// • Cấp 2 (chưa tới lượt) VẪN xem được — đây là ngữ nghĩa CỐ Ý của mã (`:611-613` hỏi
// `Any` trên MỌI Cấp của quy trình): xem-được ≠ tới-lượt. Ghim lại để lần sau ai siết
// thành "chỉ Cấp hiện tại" thì biết mình đang ĐỔI HỢP ĐỒNG, không phải sửa bug.
// ===================================================================
[Fact]
public async Task View_V2Approver_NotDrafter_CanSeeChoDuyetContract()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t3-drafter@test.local", "BCH soạn HĐ", null, []);
var a = await fix.CreateUserAsync("t3-a@test.local", "Approver Cấp 1", null, [AppRoles.Procurement]);
var b = await fix.CreateUserAsync("t3-b@test.local", "Approver Cấp 2", null, [AppRoles.Procurement]);
var other = await fix.CreateUserAsync("t3-other@test.local", "Approver quy trình KHÁC", null, [AppRoles.Procurement]);
var wf = await SeedWorkflowAsync(db, "QT-CT-T3", [[[a.Id], [b.Id]]]);
var wfOther = await SeedWorkflowAsync(db, "QT-CT-T3-KHAC", [[[other.Id]]]);
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
var draft = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangSoanThao,
"HĐ còn Nháp", wf.Id, stepIndex: null, levelOrder: null);
var actorA = As(a, AppRoles.Procurement);
// Sanity chống vacuous: role của A KHÔNG cho A xem HĐ ở phase này bằng đường legacy.
ListContractsQueryHandler.GetEligiblePhases(actorA.Roles).Should().NotContain(ContractPhase.ChoDuyet,
"nếu role `Procurement` đã tự cho xem `ChoDuyet` thì test này không đo được vế V2 nào cả");
pending.DrafterUserId.Should().NotBe(a.Id, "A phải KHÔNG phải người soạn — đó là điểm của tên test");
// ---- Vế chính: A mở được.
var dto = await ViewAsync(db, um, actorA, pending.Id);
dto.Id.Should().Be(pending.Id);
dto.ApprovalWorkflowId.Should().Be(wf.Id, "mở đúng HĐ pin đúng quy trình, không phải DTO rỗng");
dto.Phase.Should().Be(ContractPhase.ChoDuyet);
// ---- Cấp 2 chưa tới lượt vẫn XEM được (ngữ nghĩa cố ý — xem ghi chú đầu test).
(await ViewAsync(db, um, As(b, AppRoles.Procurement), pending.Id)).Id.Should().Be(pending.Id,
"mã hỏi `Any` trên MỌI Cấp của quy trình đã pin ⇒ approver Cấp sau đọc được hồ sơ "
+ "trước lượt mình (quyền XEM ≠ quyền DUYỆT — chặn duyệt là việc của service)");
// ---- 🔴 Cross-workflow: approver quy trình KHÁC vẫn 403 (đây là vế 'per-HĐ' thật sự).
var actOther = async () => await ViewAsync(db, um, As(other, AppRoles.Procurement), pending.Id);
await actOther.Should().ThrowAsync<ForbiddenException>(
"có chân duyệt ở MỘT quy trình nào đó không phải là vé xem HĐ pin quy trình KHÁC — "
+ "hỏng vế này là mở toang cho mọi approver của hệ thống");
// ---- Nháp: chính A cũng 403 (S89).
var actDraft = async () => await ViewAsync(db, um, actorA, draft.Id);
await actDraft.Should().ThrowAsync<ForbiddenException>(
"HĐ chưa trình là tài liệu riêng của người soạn; bỏ vế `isDraftPhase` thì approver "
+ "đọc được bản nháp chưa ai muốn cho xem");
// Người soạn vẫn xem được bản nháp của mình — chứng 403 ở trên đến từ DANH TÍNH, không
// phải vì HĐ nháp hỏng/không đọc được.
(await ViewAsync(db, um, As(drafter), draft.Id)).Id.Should().Be(draft.Id);
}
// ===================================================================
// (T4) LIST — vai bản-cứng (không admin) thấy HĐ `DaPhatHanh`. Spec §③-B `:27` tên #4
// (review F-01: chưa wave nào đo màn DANH SÁCH).
// + Khối 2 = kiểm hở XÓA MỀM (spec §③-B `:31`) — gộp vào đây vì cùng họ handler
// `List*ContractsQueryHandler` và cùng actor, nên đo được cặp "còn sống ⟂ đã xoá"
// chỉ khác ĐÚNG MỘT biến `IsDeleted`.
//
// Cặp đối chứng cốt lõi: CÙNG một HĐ `DaPhatHanh`, hai actor khác nhau ĐÚNG MỘT BIẾN là
// role — Director (bản-cứng) thấy, Procurement (không bản-cứng) không thấy. Đó là bằng
// chứng vế mới lọc theo `HardCopyActorRoles` chứ không phải "ai cũng thấy HĐ đã phát hành".
// ===================================================================
[Fact]
public async Task List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t4-drafter@test.local", "BCH soạn HĐ", null, []);
var director = await fix.CreateUserAsync("t4-dir@test.local", "Giám đốc ký", null, [AppRoles.Director]);
var pmh = await fix.CreateUserAsync("t4-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
var approver = await fix.CreateUserAsync("t4-ap@test.local", "Approver V2", null, [AppRoles.Procurement]);
var wf = await SeedWorkflowAsync(db, "QT-CT-T4", [[[approver.Id]]]);
var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
"HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
var trinhKy = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DangTrinhKy,
"HĐ legacy đang trình ký", null);
var actorDirector = As(director, AppRoles.Director);
actorDirector.Roles.Should().NotContain(AppRoles.Admin, "tên test ghi rõ NOT admin — actor phải thật sự không phải admin");
// ---- Khối 1: LIST còn sống.
var listDirector = await ListAsync(db, actorDirector);
listDirector.Items.Select(i => i.Id).Should().Contain(published.Id,
"vai bản-cứng khâu ký/đóng dấu phải thấy HĐ ĐÃ PHÁT HÀNH — trước W6 màn danh sách "
+ "trả 0 dòng cho họ vì `GetEligiblePhases(Director)` chỉ có `DangTrinhKy`");
listDirector.Items.Select(i => i.Id).Should().Contain(trinhKy.Id,
"vế legacy theo role vẫn nguyên (Director ↔ DangTrinhKy) — W6 chỉ CỘNG THÊM");
listDirector.Items.Select(i => i.Id).Should().NotContain(pending.Id,
"vế bản-cứng bị chặn theo PHASE (`&& Phase == DaPhatHanh`); bỏ vế phase là Director "
+ "thấy mọi HĐ đang chờ duyệt của toàn hệ thống");
// Lọc `?phase=9` — đúng đường FE gọi ở acceptance §③-B `:29`.
(await ListAsync(db, actorDirector, ContractPhase.DaPhatHanh)).Items
.Select(i => i.Id).Should().BeEquivalentTo(new[] { published.Id });
// ---- 🔴 Đối chứng 1 biến: cùng HĐ, role KHÔNG bản-cứng ⇒ không thấy.
(await ListAsync(db, As(pmh, AppRoles.Procurement))).Items.Select(i => i.Id)
.Should().NotContain(published.Id,
"`Procurement` không nằm trong `HardCopyActorRoles` ⇒ vẫn không thấy HĐ đã phát hành; "
+ "thấy được nghĩa là vế mới đang mở theo PHASE cho mọi người");
// Approver V2 thấy HĐ ChoDuyet của chính quy trình mình (vế V2 ở màn danh sách).
(await ListAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
.Should().Contain(pending.Id, "vế V2 per-HĐ cũng phải sống ở LIST, không chỉ ở Detail");
// ---- Khối 2: XÓA MỀM — không được rò sang màn "Đã xóa" (spec §③-B `:31`).
await SoftDeleteAsync(db, published);
await SoftDeleteAsync(db, pending);
(await ListAsync(db, actorDirector)).Items.Select(i => i.Id).Should().NotContain(published.Id,
"global filter `!IsDeleted` che HĐ đã xoá khỏi màn danh sách thường");
var deletedForDirector = (await ListDeletedAsync(db, actorDirector)).Items.Select(i => i.Id).ToList();
deletedForDirector.Should().NotContain(published.Id,
"màn 'Đã xóa' CỐ Ý không ghép vế bản-cứng (`ContractFeatures.cs:426-431`) — cùng HĐ, cùng "
+ "actor, chỉ đổi `IsDeleted` mà quyền lật ngược: đó là ranh giới đang được ghim");
deletedForDirector.Should().NotContain(pending.Id);
(await ListDeletedAsync(db, As(approver, AppRoles.Procurement))).Items.Select(i => i.Id)
.Should().NotContain(pending.Id,
"approver V2 thấy HĐ lúc còn sống nhưng KHÔNG được thấy ở màn Đã xóa — đây đúng là "
+ "cái lỗ mà spec cấm khi nhét `ChoDuyet` vào `GetEligiblePhases` dùng chung");
// CHỨNG NHÂN: người soạn VẪN thấy cả hai ở màn Đã xóa ⇒ 2 assert âm trên không xanh
// nhờ handler trả rỗng cho tất cả mọi người.
(await ListDeletedAsync(db, As(drafter))).Items.Select(i => i.Id)
.Should().BeEquivalentTo(new[] { published.Id, pending.Id },
"người soạn giữ nguyên quyền xem HĐ đã xoá của mình — chứng màn 'Đã xóa' vẫn chạy");
}
// ===================================================================
// (T5) ÂM — người ngoài quy trình, mang role CỤ THỂ → 403. Spec §③-B `:27` tên #5
// (review F-S4: cấm chọn role dễ-pass).
//
// Chọn `CostControl` làm actor chính là CÓ CHỦ Ý và là ca mạnh nhất: nó VỪA nằm trong
// `HardCopyActorRoles` (`ContractFeatures.cs:376`) VỪA có eligible-phase legacy
// {DangGopY, DangKiemTraCCM}. Nếu vế bản-cứng bị viết thiếu `&& Phase == DaPhatHanh` thì
// đúng người này lọt vào HĐ `ChoDuyet` — không role nào khác bắt được lỗi đó.
// ===================================================================
[Fact]
public async Task View_UserOutsideWorkflow_WithUnrelatedRole_Gets403()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t5-drafter@test.local", "BCH soạn HĐ", null, []);
var approver = await fix.CreateUserAsync("t5-ap@test.local", "Approver hợp lệ", null, []);
var ccm = await fix.CreateUserAsync("t5-ccm@test.local", "NV Kiểm soát chi phí", null, [AppRoles.CostControl]);
var pmh = await fix.CreateUserAsync("t5-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
var wf = await SeedWorkflowAsync(db, "QT-CT-T5", [[[approver.Id]]]);
var pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.ChoDuyet,
"HĐ chờ duyệt", wf.Id, stepIndex: 0, levelOrder: 1);
var published = await AddContractAsync(db, sup, proj, drafter.Id, ContractPhase.DaPhatHanh,
"HĐ đã phát hành", wf.Id, stepIndex: null, levelOrder: null);
var actorCcm = As(ccm, AppRoles.CostControl);
var actorPmh = As(pmh, AppRoles.Procurement);
// Sanity: hai actor này ĐÚNG là 'ngoài quy trình' và không phải người soạn/admin.
actorCcm.Roles.Should().NotContain(AppRoles.Admin);
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == ccm.Id || l.ApproverUserId == pmh.Id))
.Should().Be(0, "hai actor ca ÂM không được có chân ở bất kỳ Cấp nào — nếu có thì test đo nhầm vế");
// ---- 403 lớp 1: CostControl (role NẶNG: vừa bản-cứng vừa có eligible-phase legacy).
var actCcm = async () => await ViewAsync(db, um, actorCcm, pending.Id);
await actCcm.Should().ThrowAsync<ForbiddenException>(
"`CostControl` là vai bản-cứng NHƯNG chỉ cho HĐ `DaPhatHanh`; HĐ đang `ChoDuyet` mà mở "
+ "được nghĩa là vế bản-cứng thiếu chặn phase = mở toang theo role");
// ---- 403 lớp 2: Procurement (không bản-cứng, không eligible ChoDuyet).
var actPmh = async () => await ViewAsync(db, um, actorPmh, pending.Id);
await actPmh.Should().ThrowAsync<ForbiddenException>(
"role ngoài cả hai điều kiện thì phải giữ nguyên 403 như trước W6");
// ---- 🔴 CHỨNG NHÂN 1: CHÍNH `ccm` mở được HĐ `DaPhatHanh` (đổi đúng 1 biến: phase).
(await ViewAsync(db, um, actorCcm, published.Id)).Id.Should().Be(published.Id,
"cùng người, cùng role, chỉ khác phase ⇒ 403 ở trên đến từ ĐIỀU KIỆN PER-HĐ, "
+ "không phải vì guard chặn tất cả mọi thứ");
// ---- CHỨNG NHÂN 2: approver hợp lệ mở được chính HĐ `ChoDuyet` đó.
(await ViewAsync(db, um, As(approver), pending.Id)).Id.Should().Be(pending.Id,
"HĐ đó KHÔNG hỏng — đúng người vẫn mở được; 403 là về danh tính người hỏi");
// ---- Và `Procurement` vẫn 403 ngay cả với HĐ đã phát hành (không phải bản-cứng).
var actPmhPublished = async () => await ViewAsync(db, um, actorPmh, published.Id);
await actPmhPublished.Should().ThrowAsync<ForbiddenException>(
"danh sách `HardCopyActorRoles` là danh sách ĐÓNG — thêm role vào đó là quyết định "
+ "có chủ đích, không được rơi ra do sửa vặt");
}
// ===================================================================
// (T6 — BONUS, DR-4 / review F-B13) Admin thấy HĐ `ChoDuyet` trong hộp thư.
//
// Trước W6 admin lấy `eligiblePhases = PhaseActorRoles.Keys` mà dict đó 100% phase legacy
// ⇒ ghép `v2InboxIds` xong admin VẪN mù (và admin CỐ Ý có `v2InboxIds` rỗng — `:516-518`).
// Sửa = liệt kê tường minh `AdminInboxPhases` (`:487-497`).
//
// Đo bằng TẬP ĐỦ chứ không chỉ "có chứa": seed đủ 8 phase trong danh sách + 3 phase ngoài.
// Cách này bắt CẢ HAI chiều — thêm `ChoDuyet` mà đánh rơi một phase legacy cũng ĐỎ.
// Admin cố ý KHÔNG có chân trong quy trình ⇒ đường duy nhất là nhánh PHASE.
// ===================================================================
[Fact]
public async Task Inbox_Admin_SeesChoDuyetContract()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("t6-drafter@test.local", "BCH soạn HĐ", null, []);
var approver = await fix.CreateUserAsync("t6-ap@test.local", "Approver V2", null, []);
var adminUser = await fix.CreateUserAsync("t6-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var wf = await SeedWorkflowAsync(db, "QT-CT-T6", [[[approver.Id]]]);
ContractPhase[] inboxPhases =
[
ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan,
ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy,
ContractPhase.DangDongDau, ContractPhase.ChoDuyet,
];
ContractPhase[] outOfInboxPhases =
[
ContractPhase.DaPhatHanh, ContractPhase.TuChoi, ContractPhase.TraLai,
];
var expected = new List<Guid>();
foreach (var ph in inboxPhases)
{
var c = await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}",
ph == ContractPhase.ChoDuyet ? wf.Id : null,
stepIndex: ph == ContractPhase.ChoDuyet ? 0 : null,
levelOrder: ph == ContractPhase.ChoDuyet ? 1 : null);
expected.Add(c.Id);
}
var outIds = new List<Guid>();
foreach (var ph in outOfInboxPhases)
outIds.Add((await AddContractAsync(db, sup, proj, drafter.Id, ph, $"HĐ phase {ph}", null)).Id);
var admin = As(adminUser, AppRoles.Admin);
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApproverUserId == adminUser.Id))
.Should().Be(0, "admin KHÔNG có chân trong quy trình ⇒ chỉ có thể thấy qua nhánh PHASE");
var inbox = (await InboxAsync(db, admin)).Select(i => i.Id).ToList();
inbox.Should().BeEquivalentTo(expected,
"hộp thư admin = ĐÚNG 8 phase liệt tường minh: thiếu `ChoDuyet` là lỗi DR-4 gốc "
+ "(admin mù HĐ chờ duyệt), thừa/thiếu phase legacy là W6 đã lỡ tay gỡ hành-vi cũ");
inbox.Should().NotIntersectWith(outIds,
"phase kết thúc (đã phát hành / từ chối / trả lại) không phải việc đang chờ xử lý");
}
// ===================================================================
// (T7 — BONUS) Guard trình: nới đúng vai, và KHÔNG nới nhầm cho actor null.
//
// Lỗ W6 #3: NV Phòng Mua hàng (`Procurement`) lập HĐ rồi bị 403 khi bấm Trình.
// Sửa = `Drafter DeptManager Procurement người-tạo` (`ContractWorkflowService.cs:84-88`).
//
// 🔴 Ca 4 là lý do test này tồn tại. `contract.CreatedBy` và `actorUserId` ĐỀU là `Guid?`.
// Viết vế người-tạo thành `contract.CreatedBy == actorUserId` (cách tự nhiên nhất) thì HĐ
// có `CreatedBy == null` gặp actor `null` cho ra `null == null` = TRUE ⇒ một lệnh trình
// KHÔNG DANH TÍNH đi lọt. Chốt `actorUserId is Guid` ở `:82` là thứ duy nhất chặn. Ca này
// ĐỎ dưới bản viết-tự-nhiên và XANH dưới bản đang có ⇒ lưới có răng thật.
// `decision` phải là `Approve` (KHÔNG phải `AutoApprove`) để không rơi vào cửa `isSystem`.
// ===================================================================
[Fact]
public async Task Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var (sup, proj) = await SeedMasterAsync(db);
var pmh = await fix.CreateUserAsync("t7-pmh@test.local", "NV Mua hàng", null, [AppRoles.Procurement]);
var creator = await fix.CreateUserAsync("t7-creator@test.local", "Người lập HĐ", null, []);
var ketoan = await fix.CreateUserAsync("t7-kt@test.local", "Kế toán", null, [AppRoles.Finance]);
var approver = await fix.CreateUserAsync("t7-ap@test.local", "Approver V2", null, []);
var wf = await SeedWorkflowAsync(db, "QT-CT-T7", [[[approver.Id]]]);
var dt = new FixedDateTime(Clock2026);
var currentUser = new TestCurrentUser(); // mutate per ca — mô phỏng scoped-per-request
var svc = new ContractWorkflowService(
db, new ContractCodeGenerator(db, dt), dt,
new NoOpNotificationServiceApp(), new ChangelogService(db, currentUser, um), um);
async Task<Contract> NewDraftAsync(string ten, Guid drafterId)
=> await AddContractAsync(db, sup, proj, drafterId, ContractPhase.DangSoanThao, ten, wf.Id);
async Task SubmitAsync(Contract c, Guid? actorId, params string[] roles)
{
currentUser.UserId = actorId;
currentUser.Roles = roles;
await svc.TransitionAsync(c, ContractPhase.ChoDuyet, actorId, roles,
ApprovalDecision.Approve, "Trình duyệt HĐ", ct: CancellationToken.None);
}
// ---- Ca 1: `Procurement` KHÔNG phải người tạo ⇒ ĐƯỢC trình (chính là lỗ đã vá).
var c1 = await NewDraftAsync("HĐ do PMH trình", drafterId: creator.Id);
await SubmitAsync(c1, pmh.Id, AppRoles.Procurement);
c1.Phase.Should().Be(ContractPhase.ChoDuyet, "role `Procurement` giờ nằm trong vai được trình");
c1.CurrentWorkflowStepIndex.Should().Be(0);
c1.CurrentApprovalLevelOrder.Should().Be(1, "HĐ pin V2 ⇒ con-trỏ Cấp khởi tạo = 1");
// ---- Ca 2: người TẠO nhưng KHÔNG role nào ⇒ vẫn được trình.
var c2 = await NewDraftAsync("HĐ do chính người lập trình", drafterId: creator.Id);
await SubmitAsync(c2, creator.Id);
c2.Phase.Should().Be(ContractPhase.ChoDuyet,
"vế người-tạo bắt qua `DrafterUserId` — HĐ seed/legacy có thể trống `CreatedBy`");
// ---- Ca 3: role KHÔNG liên quan + không phải người tạo ⇒ giữ 403.
var c3 = await NewDraftAsync("HĐ kế toán cố trình", drafterId: creator.Id);
var act3 = async () => await SubmitAsync(c3, ketoan.Id, AppRoles.Finance);
await act3.Should().ThrowAsync<ForbiddenException>("nới vai KHÔNG được biến thành bỏ rào");
c3.Phase.Should().Be(ContractPhase.DangSoanThao, "bị chặn thì HĐ phải bất động");
// ---- Ca 4: 🔴 actor NULL + `CreatedBy` NULL ⇒ vẫn phải 403.
var c4 = await NewDraftAsync("HĐ bị trình vô danh", drafterId: creator.Id);
c4.CreatedBy.Should().BeNull(
"điều kiện tiên quyết của phép đo: fixture không wire `AuditingInterceptor` nên "
+ "`CreatedBy` để trống. Nếu ô này có giá trị thì bẫy `null == null` không thể xảy ra "
+ "và test đang xanh ở một thế giới KHÁC");
var act4 = async () => await SubmitAsync(c4, actorId: null, roles: []);
await act4.Should().ThrowAsync<ForbiddenException>(
"actor null gặp `CreatedBy` null: viết `contract.CreatedBy == actorUserId` thì "
+ "`null == null` = TRUE và lệnh trình vô danh đi lọt. Chốt `actorUserId is Guid` "
+ "(`ContractWorkflowService.cs:82`) là thứ duy nhất chặn — đây là lưới giữ nó");
c4.Phase.Should().Be(ContractPhase.DangSoanThao, "và HĐ phải nguyên vẹn sau khi bị chặn");
}
}