[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
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:
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user