[CLAUDE] Office: IT staff tự reassign ticket — authz Admin-OR-IT + capability endpoint (S54)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m17s

- BE: GetAssignableItStaffQuery {canReassign,staff} capability endpoint + AssignItTicketHandler authz Admin-OR-dept-IT (Forbidden) + assignee-must-IT (Conflict); controller /assign hạ [Authorize(Roles=Admin)]→[Authorize] (handler enforce fine-grained data-driven)
- FE: fe-admin + fe-user ItTicketsPage SHA256-identical (reverse S53 divergence), nút gate by canReassign, dropdown từ /assignable-staff (không /users)
- Test: +13 authz guard (203→216 PASS), reviewer PASS (role-string Admin chain-verified real)
- No migration (DepartmentId reuse), no menu change

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 16:12:14 +07:00
parent 18d397f095
commit ca4b60277b
13 changed files with 587 additions and 34 deletions

View File

@ -0,0 +1,363 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Office;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// S54 (2026-06-08) — SECURITY GUARD test-before-merge cho ItTicket reassign authz.
//
// READ src: WorkflowAppsFeatures.cs REGION 5 (dòng ~443-522):
// A. GetAssignableItStaffHandler(db, cu):
// itDeptId = Departments.Where(Code=="IT" && !IsDeleted).
// isAdmin = cu.Roles.Contains("Admin"); myDeptId = Users.Where(Id==cu.UserId).DepartmentId.
// isItStaff = (myDeptId == itDeptId). canReassign = isAdmin || isItStaff.
// !canReassign (hoặc không có dept IT) → (canReassign, EMPTY).
// canReassign → Staff = Users.Where(DeptId==itDeptId && IsActive).OrderBy(FullName).Select(Id,FullName).
// UserId null → UnauthorizedException.
// B. AssignItTicketHandler(db, cu, clock) — guard MỚI S54:
// caller phải Admin HOẶC myDeptId==itDeptId → else ForbiddenException.
// assignee.DepartmentId phải == itDeptId → else ConflictException("Người được giao phải thuộc tổ IT.").
// Giữ: UserId null→Unauthorized; ticket !found→NotFound; assignee !found(IsActive)→NotFound("User",id).
//
// Pattern reuse (mirror ItTicketAssignSlaTests S53): IdentityFixture +
// TestApplicationDbContext + FixedDateTime + SeedDeptAsync. Fake ICurrentUser =
// TestCurrentUser với Roles configurable (matrix Admin / IT-staff / non-IT).
//
// Mục tiêu authz: case 5 (non-IT non-admin → assign) PHẢI throw Forbidden — đây là
// case RED nếu guard chưa có. Empty-staff = 0-leak assert (Staff.Should().BeEmpty).
public class ItTicketReassignAuthzTests
{
private static readonly DateTime FixedNow = new(2026, 6, 8, 8, 0, 0, DateTimeKind.Utc);
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var clock = new FixedDateTime(FixedNow);
return (fix, db, clock);
}
// Fake ICurrentUser từ 1 User seed + roles tùy chọn (matrix Admin / [] non-admin).
private static TestCurrentUser AsUser(User u, params string[] roles)
=> new() { UserId = u.Id, FullName = u.FullName, Roles = roles ?? Array.Empty<string>() };
private static async Task<Guid> SeedDeptAsync(TestApplicationDbContext db, string code, string name)
{
var dept = new Department { Id = Guid.NewGuid(), Code = code, Name = name };
db.Departments.Add(dept);
await db.SaveChangesAsync(CancellationToken.None);
return dept.Id;
}
// Seed 1 ticket OPEN unassigned để reassign — bỏ qua codegen (mã thủ công).
private static async Task<Guid> SeedTicketAsync(TestApplicationDbContext db, Guid requesterId)
{
var t = new ItTicket
{
Id = Guid.NewGuid(),
MaTicket = "IT/2026/901",
RequesterUserId = requesterId,
RequesterFullName = "seed-req",
Title = "Ticket cần gán",
Description = "reassign test",
Category = ItTicketCategory.Hardware,
Priority = ItTicketPriority.Medium,
Status = ItTicketStatus.Open,
CreatedAt = FixedNow,
};
db.ItTickets.Add(t);
await db.SaveChangesAsync(CancellationToken.None);
return t.Id;
}
// =====================================================================
// GetAssignableItStaff — capability + staff scoping
// =====================================================================
// Case 1: Admin (dept null, KHÔNG ở IT) → CanReassign=true + Staff = đúng IT-active,
// ordered theo FullName, KHÔNG lẫn dept khác / inactive.
[Fact]
public async Task GetAssignableItStaff_AdminCaller_ReturnsCanReassignAndOrderedItStaff()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
// Admin dept null (chứng minh canReassign do role, không do dept IT).
var admin = await fix.CreateUserAsync("admin@test.local", "Quản trị", null, new[] { "Admin" });
// IT active — "Cao" và "Truong" (verify OrderBy FullName: Cao < Truong).
await fix.CreateUserAsync("it-truong@test.local", "Truong", itDeptId, Array.Empty<string>());
await fix.CreateUserAsync("it-cao@test.local", "Cao", itDeptId, Array.Empty<string>());
// Noise: KT user + IT inactive — KHÔNG được xuất hiện.
await fix.CreateUserAsync("kt-1@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
var inactiveIt = await fix.CreateUserAsync("it-off@test.local", "IT Nghi", itDeptId, Array.Empty<string>());
inactiveIt.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetAssignableItStaffHandler(db, AsUser(admin, "Admin"));
var res = await handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None);
res.CanReassign.Should().BeTrue("Admin luôn được reassign bất kể dept");
res.Staff.Should().HaveCount(2, "chỉ 2 IT active — KT user + IT inactive bị loại");
res.Staff.Select(s => s.FullName).Should().ContainInOrder("Cao", "Truong");
res.Staff.Select(s => s.FullName).Should().NotContain("Ke Toan", "user dept khác không leak");
res.Staff.Select(s => s.FullName).Should().NotContain("IT Nghi", "IT inactive không leak");
}
}
// Case 2: IT staff (DeptId==IT, KHÔNG role Admin) → CanReassign=true + Staff = IT members.
[Fact]
public async Task GetAssignableItStaff_ItStaffCaller_ReturnsCanReassignAndItMembers()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var caller = await fix.CreateUserAsync("it-self@test.local", "An", itDeptId, Array.Empty<string>());
await fix.CreateUserAsync("it-binh@test.local", "Binh", itDeptId, Array.Empty<string>());
// Caller KHÔNG có role Admin (Roles rỗng) → canReassign do isItStaff.
var handler = new GetAssignableItStaffHandler(db, AsUser(caller));
var res = await handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None);
res.CanReassign.Should().BeTrue("thành viên tổ IT được reassign dù không phải Admin");
res.Staff.Select(s => s.FullName).Should().ContainInOrder("An", "Binh");
res.Staff.Should().HaveCount(2);
}
}
// Case 3: non-IT non-admin (dept KT) → CanReassign=false + Staff EMPTY (0-leak).
[Fact]
public async Task GetAssignableItStaff_NonItNonAdmin_ReturnsCannotAndEmptyStaff()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
// Dù có IT staff tồn tại, caller non-IT non-admin KHÔNG được thấy.
await fix.CreateUserAsync("it-x@test.local", "IT X", itDeptId, Array.Empty<string>());
var caller = await fix.CreateUserAsync("kt-caller@test.local", "Ke Toan Vien", ktDeptId, Array.Empty<string>());
var handler = new GetAssignableItStaffHandler(db, AsUser(caller));
var res = await handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None);
res.CanReassign.Should().BeFalse("không Admin + không ở tổ IT → cấm reassign");
res.Staff.Should().BeEmpty("không leak danh sách IT staff cho người không có quyền");
}
}
// Case 3b: caller dept null + non-admin → cũng false + empty (myDeptId==null != itDeptId).
[Fact]
public async Task GetAssignableItStaff_NoDeptNonAdmin_ReturnsCannotAndEmptyStaff()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
await fix.CreateUserAsync("it-y@test.local", "IT Y", itDeptId, Array.Empty<string>());
var caller = await fix.CreateUserAsync("nodept@test.local", "Khong Phong", null, Array.Empty<string>());
var handler = new GetAssignableItStaffHandler(db, AsUser(caller));
var res = await handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None);
res.CanReassign.Should().BeFalse("dept null + non-admin → myDeptId != itDeptId");
res.Staff.Should().BeEmpty();
}
}
// Case 4 (optional): IT staff caller nhưng dept IT có user inactive → inactive bị loại.
[Fact]
public async Task GetAssignableItStaff_ExcludesInactiveItMembers()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var caller = await fix.CreateUserAsync("it-lead@test.local", "Active Lead", itDeptId, Array.Empty<string>());
var inactive = await fix.CreateUserAsync("it-dead@test.local", "Inactive Mem", itDeptId, Array.Empty<string>());
inactive.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetAssignableItStaffHandler(db, AsUser(caller));
var res = await handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None);
res.CanReassign.Should().BeTrue();
res.Staff.Should().ContainSingle("chỉ caller active còn lại sau khi loại inactive");
res.Staff.Single().FullName.Should().Be("Active Lead");
}
}
// Case (guard): UserId null → UnauthorizedException.
[Fact]
public async Task GetAssignableItStaff_NoUserId_ThrowsUnauthorized()
{
var (fix, db, _) = NewCtx();
using (fix)
{
var handler = new GetAssignableItStaffHandler(db, new TestCurrentUser());
await FluentActions.Awaiting(() => handler.Handle(new GetAssignableItStaffQuery(), CancellationToken.None))
.Should().ThrowAsync<UnauthorizedException>();
}
}
// =====================================================================
// AssignItTicket — authz guard + assignee constraint
// =====================================================================
// Case 5: non-IT non-admin caller → assign → ForbiddenException (case RED nếu thiếu guard).
[Fact]
public async Task AssignItTicket_NonItNonAdminCaller_ThrowsForbidden()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
var assignee = await fix.CreateUserAsync("it-assignee@test.local", "IT Assignee", itDeptId, Array.Empty<string>());
var caller = await fix.CreateUserAsync("kt-bad@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
var ticketId = await SeedTicketAsync(db, caller.Id);
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
var cmd = new AssignItTicketCommand(ticketId, assignee.Id);
await FluentActions.Awaiting(() => handler.Handle(cmd, CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("caller không Admin + không ở tổ IT bị chặn reassign");
// Side-effect guard: assign KHÔNG được áp dụng khi bị Forbidden.
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
t.AssignedToUserId.Should().BeNull("Forbidden → không mutate AssignedToUserId");
}
}
// Case 6: Admin caller, assignee ∈ IT → success (set AssignedTo* đúng).
[Fact]
public async Task AssignItTicket_AdminCaller_AssigneeInIt_Succeeds()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var admin = await fix.CreateUserAsync("admin2@test.local", "Quản trị", null, new[] { "Admin" });
var assignee = await fix.CreateUserAsync("it-target@test.local", "IT Target", itDeptId, Array.Empty<string>());
var ticketId = await SeedTicketAsync(db, admin.Id);
var handler = new AssignItTicketHandler(db, AsUser(admin, "Admin"), clock);
await handler.Handle(new AssignItTicketCommand(ticketId, assignee.Id), CancellationToken.None);
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
t.AssignedToUserId.Should().Be(assignee.Id);
t.AssignedToFullName.Should().Be("IT Target", "denorm full name set từ User.FullName");
t.UpdatedBy.Should().Be(admin.Id);
}
}
// Case 7: IT staff caller, assignee ∈ IT → success.
[Fact]
public async Task AssignItTicket_ItStaffCaller_AssigneeInIt_Succeeds()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var caller = await fix.CreateUserAsync("it-caller@test.local", "IT Caller", itDeptId, Array.Empty<string>());
var assignee = await fix.CreateUserAsync("it-peer@test.local", "IT Peer", itDeptId, Array.Empty<string>());
var ticketId = await SeedTicketAsync(db, caller.Id);
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
await handler.Handle(new AssignItTicketCommand(ticketId, assignee.Id), CancellationToken.None);
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
t.AssignedToUserId.Should().Be(assignee.Id, "IT staff được reassign cho IT peer");
t.AssignedToFullName.Should().Be("IT Peer");
}
}
// Case 8: Admin caller, assignee KHÔNG thuộc IT → ConflictException.
[Fact]
public async Task AssignItTicket_AssigneeOutsideIt_ThrowsConflict()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
var admin = await fix.CreateUserAsync("admin3@test.local", "Quản trị", null, new[] { "Admin" });
// Assignee thuộc KT, KHÔNG phải IT → vi phạm constraint.
var assignee = await fix.CreateUserAsync("kt-target@test.local", "KT Target", ktDeptId, Array.Empty<string>());
var ticketId = await SeedTicketAsync(db, admin.Id);
var handler = new AssignItTicketHandler(db, AsUser(admin, "Admin"), clock);
await FluentActions.Awaiting(() => handler.Handle(new AssignItTicketCommand(ticketId, assignee.Id), CancellationToken.None))
.Should().ThrowAsync<ConflictException>()
.WithMessage("Người được giao phải thuộc tổ IT.");
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
t.AssignedToUserId.Should().BeNull("Conflict → không mutate assignment");
}
}
// Case 9 (optional): assignee inactive → NotFoundException("User",...).
[Fact]
public async Task AssignItTicket_AssigneeInactive_ThrowsNotFound()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var admin = await fix.CreateUserAsync("admin4@test.local", "Quản trị", null, new[] { "Admin" });
var inactive = await fix.CreateUserAsync("it-inact@test.local", "IT Inactive", itDeptId, Array.Empty<string>());
inactive.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
var ticketId = await SeedTicketAsync(db, admin.Id);
var handler = new AssignItTicketHandler(db, AsUser(admin, "Admin"), clock);
await FluentActions.Awaiting(() => handler.Handle(new AssignItTicketCommand(ticketId, inactive.Id), CancellationToken.None))
.Should().ThrowAsync<NotFoundException>("assignee phải IsActive — inactive coi như not found");
}
}
// Case (guard): ticket không tồn tại → NotFoundException("ItTicket",...). Authz pass trước.
[Fact]
public async Task AssignItTicket_TicketNotFound_ThrowsNotFound()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var admin = await fix.CreateUserAsync("admin5@test.local", "Quản trị", null, new[] { "Admin" });
var handler = new AssignItTicketHandler(db, AsUser(admin, "Admin"), clock);
await FluentActions.Awaiting(() =>
handler.Handle(new AssignItTicketCommand(Guid.NewGuid(), admin.Id), CancellationToken.None))
.Should().ThrowAsync<NotFoundException>();
}
}
// Case (guard): UserId null → UnauthorizedException (trước cả ticket lookup).
[Fact]
public async Task AssignItTicket_NoUserId_ThrowsUnauthorized()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var handler = new AssignItTicketHandler(db, new TestCurrentUser(), clock);
await FluentActions.Awaiting(() =>
handler.Handle(new AssignItTicketCommand(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None))
.Should().ThrowAsync<UnauthorizedException>();
}
}
}