[CLAUDE] PurchaseEvaluation: co gap GAN=NV chuc nang / GO=chi Truong phong (DeptManager) bat doi xung
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m48s

Tra Sol + anh Kiet (Zalo chot 14:25): tranh NV khac lo tay go co cua nguoi da gan. Handler SetPurchaseEvaluationUrgent gate BAT DOI XUNG theo IsUrgent: GAN (true) = role chuc nang (Procurement->do / CostControl->xanh / Admin->ca 2); GO (false) = role chuc nang + DeptManager (Truong phong) hoac Admin. FE PeDetailTabs nut toggle gate theo trang thai hien tai (da gap->can quyen GO; chua gap->can quyen GAN) → an nut GO voi NV thuong. Test PeUrgentToggleAuthzTests rewrite asymmetric (354 PASS 0 fail). FE 2 app SHA256-identical. Build slnx 0-err.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 14:42:08 +07:00
parent e29391ec9e
commit b5aa72d005
4 changed files with 188 additions and 79 deletions

View File

@ -126,8 +126,16 @@ export function PeDetailTabs({
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security. // BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
const isPro = currentUser?.roles?.includes('Procurement') ?? false const isPro = currentUser?.roles?.includes('Procurement') ?? false
const isCcm = currentUser?.roles?.includes('CostControl') ?? false const isCcm = currentUser?.roles?.includes('CostControl') ?? false
const canToggleProUrgent = isAdmin || isPro // [S77 Tra Sol/anh Kiệt — chốt] BẤT ĐỐI XỨNG: GẮN = NV chức năng (ai làm nấy gắn);
const canToggleCcmUrgent = isAdmin || isCcm // GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc
// trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN.
const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false
const canToggleProUrgent = evaluation.isUrgentByPro
? (isAdmin || (isPro && isDeptManager))
: (isAdmin || isPro)
const canToggleCcmUrgent = evaluation.isUrgentByCcm
? (isAdmin || (isCcm && isDeptManager))
: (isAdmin || isCcm)
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))

View File

@ -126,8 +126,16 @@ export function PeDetailTabs({
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security. // BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
const isPro = currentUser?.roles?.includes('Procurement') ?? false const isPro = currentUser?.roles?.includes('Procurement') ?? false
const isCcm = currentUser?.roles?.includes('CostControl') ?? false const isCcm = currentUser?.roles?.includes('CostControl') ?? false
const canToggleProUrgent = isAdmin || isPro // [S77 Tra Sol/anh Kiệt — chốt] BẤT ĐỐI XỨNG: GẮN = NV chức năng (ai làm nấy gắn);
const canToggleCcmUrgent = isAdmin || isCcm // GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc
// trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN.
const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false
const canToggleProUrgent = evaluation.isUrgentByPro
? (isAdmin || (isPro && isDeptManager))
: (isAdmin || isPro)
const canToggleCcmUrgent = evaluation.isUrgentByCcm
? (isAdmin || (isCcm && isDeptManager))
: (isAdmin || isCcm)
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))

View File

@ -38,11 +38,21 @@ public class SetPurchaseEvaluationUrgentCommandHandler(
var roles = currentUser.Roles; var roles = currentUser.Roles;
var isAdmin = roles.Contains(AppRoles.Admin); var isAdmin = roles.Contains(AppRoles.Admin);
var isPro = roles.Contains(AppRoles.Procurement); var isDeptManager = roles.Contains(AppRoles.DeptManager);
var isCcm = roles.Contains(AppRoles.CostControl); var hasPro = roles.Contains(AppRoles.Procurement);
var hasCcm = roles.Contains(AppRoles.CostControl);
// [S77 Tra Sol/anh Kiệt — chốt 14:25] BẤT ĐỐI XỨNG theo IsUrgent:
// • GẮN (true): "ai làm người đó gắn" → NV chức năng PRO/CCM tự đánh dấu (chỉ cần role chức năng).
// • GỠ (false): "gỡ thì chỉ cho TP gỡ" → CHỈ Trưởng phòng (DeptManager) của phòng đó hoặc Admin
// (tránh NV khác lỡ tay gỡ cờ của người đã gắn).
var isPro = request.IsUrgent ? hasPro : (hasPro && isDeptManager);
var isCcm = request.IsUrgent ? hasCcm : (hasCcm && isDeptManager);
if (!isAdmin && !isPro && !isCcm) if (!isAdmin && !isPro && !isCcm)
throw new ForbiddenException("Chỉ PRO (Procurement) / CCM (CostControl) / Admin được đánh dấu phiếu gấp."); throw new ForbiddenException(request.IsUrgent
? "Chỉ PRO (Cung ứng) / CCM (Kiểm soát chi phí) / Admin được đánh dấu phiếu gấp."
: "Chỉ Trưởng phòng Cung ứng / Kiểm soát chi phí hoặc Admin được GỠ cờ gấp.");
// Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần. // Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần.
var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm; var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm;

View File

@ -15,16 +15,31 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// SetPurchaseEvaluationUrgentCommandHandler (PurchaseEvaluationUrgentFeatures.cs). // SetPurchaseEvaluationUrgentCommandHandler (PurchaseEvaluationUrgentFeatures.cs).
// Test theo CODE đã land (S34 rule — KHÔNG touch production). // Test theo CODE đã land (S34 rule — KHÔNG touch production).
// //
// Logic role → cờ: // [SPEC CHANGE — FINAL anh chốt, 2026-06-19] Gate BẤT ĐỐI XỨNG theo request.IsUrgent
// - Procurement (PRO) → set IsUrgentByPro (IsUrgentByCcm KHÔNG đụng) // (KHÁC bản S77 trước đó vốn ĐỐI XỨNG yêu cầu DeptManager cho cả set + unset):
// - CostControl (CCM) → set IsUrgentByCcm (IsUrgentByPro KHÔNG đụng) //
// - Admin → set CẢ 2 cờ // SET (IsUrgent=true) — "ai làm người đó gắn": CHỈ cần role chức năng.
// - role khác (Drafter/Finance/...) → ForbiddenException (không lưu gì) // • Procurement (KHÔNG cần DeptManager) set IsUrgentByPro (cờ ĐỎ)
// • CostControl (KHÔNG cần DeptManager) → set IsUrgentByCcm (cờ XANH)
// • Admin → set CẢ 2 cờ
// • role khác → ForbiddenException ("*đánh dấu phiếu gấp*")
//
// UNSET (IsUrgent=false) — "gỡ thì chỉ cho TP gỡ": role chức năng VÀ DeptManager.
// • Procurement + DeptManager → clear IsUrgentByPro
// • CostControl + DeptManager → clear IsUrgentByCcm
// • Admin → clear CẢ 2 (KHÔNG cần DeptManager)
// • plain Procurement (no DeptManager) → ForbiddenException + NO mutation
// • plain CostControl (no DeptManager) → ForbiddenException + NO mutation
// • role khác → ForbiddenException ("*GỠ cờ gấp*")
//
// Production gate (PurchaseEvaluationUrgentFeatures.cs:49-55):
// isPro = IsUrgent ? hasPro : (hasPro && isDeptManager);
// isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager);
// if (!isAdmin && !isPro && !isCcm) throw Forbidden(<msg theo IsUrgent>);
// //
// Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert // Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert
// notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt // notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt
// call → try block không throw. UserManager lấy từ IdentityFixture (GetUsersInRoleAsync // call → try block không throw. UserManager lấy từ IdentityFixture.
// trả rỗng khi không seed Director — best-effort no-op, đúng visibility-only Q3).
// //
// Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService). // Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService).
// FakeCurrentUser configurable Roles per scenario. // FakeCurrentUser configurable Roles per scenario.
@ -70,10 +85,11 @@ public class PeUrgentToggleAuthzTests
} }
// ===================================================================== // =====================================================================
// 1. Procurement → set CHỈ IsUrgentByPro=true, IsUrgentByCcm KHÔNG đụng. // 1. SET — plain Procurement (KHÔNG DeptManager) → OK, set CHỈ IsUrgentByPro=true,
// ByCcm KHÔNG đụng. "Ai làm người đó gắn" — không cần Trưởng phòng để bật.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task Procurement_SetsOnlyIsUrgentByPro_CcmUntouched() public async Task PlainProcurement_Set_SetsOnlyIsUrgentByPro_CcmUntouched()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
@ -84,15 +100,16 @@ public class PeUrgentToggleAuthzTests
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro)"); reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro) khi bật — chỉ cần role Procurement");
reloaded.IsUrgentByCcm.Should().BeFalse("PRO KHÔNG đụng cờ XANH (ByCcm)"); reloaded.IsUrgentByCcm.Should().BeFalse("PRO KHÔNG đụng cờ XANH (ByCcm)");
} }
// ===================================================================== // =====================================================================
// 2. CostControl → set CHỈ IsUrgentByCcm=true, IsUrgentByPro KHÔNG đụng. // 2. SET — plain CostControl (KHÔNG DeptManager) → OK, set CHỈ IsUrgentByCcm=true,
// ByPro KHÔNG đụng. Đối xứng test 1 cho phía CCM.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task CostControl_SetsOnlyIsUrgentByCcm_ProUntouched() public async Task PlainCostControl_Set_SetsOnlyIsUrgentByCcm_ProUntouched()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
@ -103,15 +120,15 @@ public class PeUrgentToggleAuthzTests
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm)"); reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm) khi bật — chỉ cần role CostControl");
reloaded.IsUrgentByPro.Should().BeFalse("CCM KHÔNG đụng cờ ĐỎ (ByPro)"); reloaded.IsUrgentByPro.Should().BeFalse("CCM KHÔNG đụng cờ ĐỎ (ByPro)");
} }
// ===================================================================== // =====================================================================
// 3. Admin → set CẢ 2 cờ true. // 3. SET — Admin → set CẢ 2 cờ true (KHÔNG cần DeptManager — superuser).
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task Admin_SetsBothFlags() public async Task Admin_Set_SetsBothFlags()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
@ -127,22 +144,133 @@ public class PeUrgentToggleAuthzTests
} }
// ===================================================================== // =====================================================================
// 4. Role không có quyền (Drafter) → ForbiddenException, KHÔNG lưu gì. // 4. UNSET — plain Procurement (KHÔNG DeptManager) → ForbiddenException + NO mutation.
// "Gỡ thì chỉ cho TP gỡ": NV PRO thường KHÔNG được gỡ cờ → cờ ĐỎ giữ true.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task Drafter_ThrowsForbidden_NoFlagSet() public async Task PlainProcurement_Unset_ThrowsForbidden_FlagStillTrue()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>(); var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-004"); // Pre-seed cờ ĐỎ đang bật để chứng minh GỠ bị chặn KHÔNG mutate.
var pe = await SeedPeAsync(db, urgentByPro: true, code: "PE-URG-004");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*GỠ cờ gấp*");
// Guard throw TRƯỚC mutate → cờ ĐỎ giữ nguyên true (NV thường không gỡ được).
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("PRO thường bị chặn gỡ → cờ ĐỎ giữ nguyên");
reloaded.IsUrgentByCcm.Should().BeFalse();
}
// =====================================================================
// 5. UNSET — plain CostControl (KHÔNG DeptManager) → ForbiddenException + NO mutation.
// Đối xứng test 4 cho phía CCM (cờ XANH giữ true).
// =====================================================================
[Fact]
public async Task PlainCostControl_Unset_ThrowsForbidden_FlagStillTrue()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URG-005");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*GỠ cờ gấp*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByCcm.Should().BeTrue("CCM thường bị chặn gỡ → cờ XANH giữ nguyên");
reloaded.IsUrgentByPro.Should().BeFalse();
}
// =====================================================================
// 6. UNSET — Procurement + DeptManager → OK, clear IsUrgentByPro=false.
// Trưởng phòng PRO ĐƯỢC gỡ cờ ĐỎ. ByCcm KHÔNG đụng (vd CCM bật trước đó vẫn giữ).
// =====================================================================
[Fact]
public async Task ProcurementDeptManager_Unset_ClearsOnlyProFlag_CcmPreserved()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// State: cả 2 cờ đang bật (PRO ĐỎ + CCM XANH).
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-006");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.DeptManager));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("Trưởng phòng PRO gỡ cờ ĐỎ của mình");
reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng");
}
// =====================================================================
// 7. UNSET — CostControl + DeptManager → OK, clear IsUrgentByCcm=false.
// Đối xứng test 6 cho phía CCM. ByPro giữ nguyên.
// =====================================================================
[Fact]
public async Task CostControlDeptManager_Unset_ClearsOnlyCcmFlag_ProPreserved()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-007");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl, AppRoles.DeptManager));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByCcm.Should().BeFalse("Trưởng phòng CCM gỡ cờ XANH của mình");
reloaded.IsUrgentByPro.Should().BeTrue("cờ ĐỎ của PRO giữ nguyên — CCM không đụng");
}
// =====================================================================
// 8. UNSET — Admin → clear CẢ 2 cờ (KHÔNG cần DeptManager — superuser).
// =====================================================================
[Fact]
public async Task Admin_Unset_ClearsBothFlags()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-008");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("Admin gỡ cả ĐỎ");
reloaded.IsUrgentByCcm.Should().BeFalse("Admin gỡ cả XANH");
}
// =====================================================================
// 9a. SET — role ngoài allow-list (Drafter) → ForbiddenException, KHÔNG lưu gì.
// Message nhánh SET ("*đánh dấu phiếu gấp*").
// =====================================================================
[Fact]
public async Task Drafter_Set_ThrowsForbidden_NoFlagSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-009A");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Drafter)); var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle( var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>() await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*PRO*CCM*Admin*"); .WithMessage("*đánh dấu phiếu gấp*");
// Side-effect: guard throw TRƯỚC mutate → cả 2 cờ giữ false. // Side-effect: guard throw TRƯỚC mutate → cả 2 cờ giữ false.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -151,73 +279,28 @@ public class PeUrgentToggleAuthzTests
} }
// ===================================================================== // =====================================================================
// 4b. Role Finance (cũng ngoài allow-list) → ForbiddenException. Cover thêm 1 // 9b. SET — Finance (role thường khác) → ForbiddenException. Chắc chắn KHÔNG chỉ
// role thường để chắc chắn KHÔNG chỉ Drafter bị chặn. // Drafter bị chặn — mọi role ngoài {Procurement, CostControl, Admin} đều bị chặn.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task Finance_ThrowsForbidden() public async Task Finance_Set_ThrowsForbidden()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>(); var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-004B"); var pe = await SeedPeAsync(db, code: "PE-URG-009B");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance)); var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance));
var act = async () => await handler.Handle( var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>(); await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*đánh dấu phiếu gấp*");
} }
// ===================================================================== // =====================================================================
// 5. IsUrgent=false (tắt cờ) — PRO tắt chỉ ByPro, ByCcm giữ nguyên (vd CCM đã // 9c. PE không tồn tại → NotFoundException (existence check đầu handler, TRƯỚC authz).
// bật trước đó). Verify partial-clear theo role + KHÔNG đụng cờ role khác. // Dùng plain Procurement để chắc NotFound bắn trước cả khi role hợp lệ.
// =====================================================================
[Fact]
public async Task Procurement_TurnOff_ClearsOnlyProFlag_CcmFlagPreserved()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// State: cả 2 cờ đang bật (PRO đã bật ĐỎ, CCM đã bật XANH).
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-005");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("PRO tắt cờ ĐỎ của mình");
reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng");
}
// =====================================================================
// 6. Multi-role actor có CẢ Procurement (không Admin) — vẫn chỉ là PRO-path
// (Admin > PRO > CCM trong if-else). Đây actor PRO → set ByPro only.
// (Edge: nếu sau này role priority đổi → test này đỏ → review chủ đích.)
// =====================================================================
[Fact]
public async Task ActorWithBothProAndCcmRoles_NoAdmin_SetsBothFlagsViaElseIfChain_LocksBehavior()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-006");
// Actor mang CẢ Procurement + CostControl (KHÔNG Admin).
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.CostControl));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
// Code path (line 51-63): isAdmin=false → else-if isPro=true → CHỈ set ByPro
// (else-if chain ngắn mạch, KHÔNG vào nhánh isCcm). LOCK behavior hiện tại:
// PRO ưu tiên hơn CCM khi user kiêm 2 role nhưng không Admin → chỉ ByPro.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("PRO branch chạy (else-if đầu khớp)");
reloaded.IsUrgentByCcm.Should().BeFalse(
"else-if ngắn mạch — actor kiêm PRO+CCM (no Admin) chỉ set ByPro, KHÔNG vào nhánh CCM");
}
// =====================================================================
// 7. PE không tồn tại → NotFoundException (guard đầu handler trước authz).
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task UnknownPe_ThrowsNotFound() public async Task UnknownPe_ThrowsNotFound()