From 761d8183f365c68d2fca13ab27f2ee810fdbc30e Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 29 Jul 2026 23:27:19 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20App+Infra+Tests:=20W6=20KHKK=20?= =?UTF-8?q?=E2=80=94=20duong=20ong=20HD=20V2=20(view=20per-HD=203-site=20+?= =?UTF-8?q?=20inbox=20V2+ADMIN=20+=20guard=20trinh=20+PMH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Contracts/ContractFeatures.cs | 142 +++- .../Services/ContractWorkflowService.cs | 15 +- .../Application/ContractV2PipelineTests.cs | 660 ++++++++++++++++++ 3 files changed, 811 insertions(+), 6 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index 024b6c7..d7c389c 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -299,7 +299,19 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu { var userId = currentUser.UserId; 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); @@ -349,6 +361,38 @@ public class ListContractsQueryHandler(IApplicationDbContext db, ICurrentUser cu AddIfAny([AppRoles.HrAdmin], ContractPhase.DangDongDau); 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 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<Guid?>` 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> 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) ========== @@ -379,6 +423,11 @@ public class ListDeletedContractsQueryHandler(IApplicationDbContext db, ICurrent { var userId = currentUser.UserId; 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)); } @@ -429,6 +478,24 @@ public class GetMyInboxQueryHandler( [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> Handle(GetMyInboxQuery request, CancellationToken ct) { 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) var eligiblePhases = isAdmin - ? PhaseActorRoles.Keys.ToList() + ? AdminInboxPhases.ToList() : PhaseActorRoles .Where(kv => kv.Value.Any(r => userRoles.Contains(r))) .Select(kv => kv.Key) .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() + : 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() join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.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 select new ContractListItemDto( c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase, @@ -458,6 +533,48 @@ public class GetMyInboxQueryHandler( 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> ResolveV2InboxIdsAsync(Guid? userId, CancellationToken ct) + { + if (userId is null) return new HashSet(); + + 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(); + + 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(); + 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 ========== @@ -485,7 +602,22 @@ public class GetContractQueryHandler( var isDrafter = c.DrafterUserId == currentUser.UserId; var eligiblePhases = ListContractsQueryHandler.GetEligiblePhases(currentUser.Roles); 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."); } diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 1da58ad..1b6fd91 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -70,9 +70,22 @@ public class ContractWorkflowService( if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai) && (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 && !actorRoles.Contains(AppRoles.Drafter) - && !actorRoles.Contains(AppRoles.DeptManager)) + && !actorRoles.Contains(AppRoles.DeptManager) + && !actorRoles.Contains(AppRoles.Procurement) + && !isCreator) { throw new ForbiddenException( $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ."); diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs new file mode 100644 index 0000000..2d4768f --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractV2PipelineTests.cs @@ -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()` 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 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 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> InboxAsync( + TestApplicationDbContext db, TestCurrentUser actor) + => new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None); + + private static Task> ListAsync( + TestApplicationDbContext db, TestCurrentUser actor, ContractPhase? phase = null) + => new ListContractsQueryHandler(db, actor) + .Handle(new ListContractsQuery(Phase: phase) { PageSize = 200 }, CancellationToken.None); + + private static Task> ListDeletedAsync( + TestApplicationDbContext db, TestCurrentUser actor) + => new ListDeletedContractsQueryHandler(db, actor) + .Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None); + + private static Task ViewAsync( + TestApplicationDbContext db, UserManager 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(); + 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(); + 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(); + var um = fix.Services.GetRequiredService>(); + 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( + "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( + "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(); + 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(); + var um = fix.Services.GetRequiredService>(); + 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( + "`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( + "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( + "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(); + 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(); + 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(); + 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(); + var um = fix.Services.GetRequiredService>(); + 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 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("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( + "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"); + } +}