From ca4b60277b3c2f21bee181b981aa5f17251fd0d9 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 8 Jun 2026 16:12:14 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Office:=20IT=20staff=20t=E1=BB=B1=20?= =?UTF-8?q?reassign=20ticket=20=E2=80=94=20authz=20Admin-OR-IT=20+=20capab?= =?UTF-8?q?ility=20endpoint=20(S54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../implementer-backend/MEMORY.md | 1 + ...ler_lower_authorize_handler_finegrained.md | 18 + ...ped_capability_endpoint_anti_silent_403.md | 18 + .../implementer-frontend/MEMORY.md | 1 + .claude/agent-memory/reviewer/MEMORY.md | 2 + .../agent-memory/test-specialist/MEMORY.md | 1 + fe-admin/src/pages/office/ItTicketsPage.tsx | 50 ++- fe-admin/src/types/workflowApps.ts | 3 + fe-user/src/pages/office/ItTicketsPage.tsx | 103 ++++- fe-user/src/types/workflowApps.ts | 3 + .../Controllers/ItTicketsController.cs | 9 +- .../Office/WorkflowAppsFeatures.cs | 49 +++ .../Application/ItTicketReassignAuthzTests.cs | 363 ++++++++++++++++++ 13 files changed, 587 insertions(+), 34 deletions(-) create mode 100644 .claude/agent-memory/implementer-backend/pattern_controller_lower_authorize_handler_finegrained.md create mode 100644 .claude/agent-memory/implementer-backend/pattern_scoped_capability_endpoint_anti_silent_403.md create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/ItTicketReassignAuthzTests.cs diff --git a/.claude/agent-memory/implementer-backend/MEMORY.md b/.claude/agent-memory/implementer-backend/MEMORY.md index 5ab2f7f..d8838c8 100644 --- a/.claude/agent-memory/implementer-backend/MEMORY.md +++ b/.claude/agent-memory/implementer-backend/MEMORY.md @@ -74,6 +74,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i ## 📅 Recent activity (FIFO — older → archive/git) +- **S54 ItTicket reassign cross-stack — IT-staff self-service (NO migration, 2 BE file edit):** NEW `GetAssignableItStaffQuery`+`AssignableStaffResult(CanReassign,Staff)`+`AssignableStaffDto(Id,FullName)` capability endpoint (REGION 5 WorkflowAppsFeatures.cs) + MODIFIED `AssignItTicketHandler`: authz Admin-OR-dept-IT → `ForbiddenException`; assignee-must-be-IT → `ConflictException`. Controller `/assign` hạ `[Authorize(Roles="Admin")]`→`[Authorize]` (handler enforce fine-grained data-driven) + NEW `GET /assignable-staff`. Predicate IT = reuse round-robin S52 `Departments.Where(Code=="IT" && !IsDeleted)`. `ICurrentUser` KHÔNG có DepartmentId → query `db.Users.Where(Id==cu.UserId).Select(DepartmentId)`. 2 pattern split (em main reconciled từ stray src/Backend/.claude — cwd-relative Write mishap): [[pattern-controller-lower-authorize-handler-finegrained]] + [[pattern-scoped-capability-endpoint-anti-silent-403]]. Build 0/0, test 203→216 (test-specialist +13 authz), reviewer PASS (role-string "Admin" chain-verified real: AppRoles→SeedRoles→JWT ClaimTypes.Role→cu.Roles). Tag `[s54, it-ticket-reassign, capability-endpoint, authz-handler, no-mig]`. - **S54 Task D BE — promote AttendanceReport to sidebar menu leaf (NO migration, 2 file edit):** Case 1 mechanical, menu = DbInitializer idempotent seed (not schema). 3 insert: (1) MenuKeys.cs const `OffAttendanceReport = "Off_AttendanceReport"` after OffChamCong:124 · (2) MenuKeys.cs All[] Off-group line +`OffAttendanceReport` after OffChamCong:158-159 · (3) DbInitializer.cs menu tuple `(OffAttendanceReport, "Báo cáo chấm công", Off, 8, "FileBarChart")` after OffChamCong:1787 (Order 8, parent Off, mirror Vehicle/Driver S51). **adminPermAutoViaAll=TRUE verified 2-point:** `SeedAdminPermissionsAsync` DbInitializer:1916 iterates `MenuKeys.All` → full-CRUD Permission row per missing key (idempotent `existingMenuKeys.Contains`); `Program.cs:78` iterates All × Actions → policy registration. +All[] = both auto, NO manual grant. **Idempotent-add verified:** menu upsert loop DbInitializer:1845-1862 `existingItems.TryGetValue(key)` miss → `MenuItems.Add` (existing prod gets leaf on restart, existing rows only Order-reconciled — same as S51). Build 0 err (2 pre-existing DocxRenderer warn). KHÔNG touch FE (menuKeys.ts/Layout = implementer-frontend) / tests / commit. Tag `[s54, task-d, menu-leaf, no-mig, admin-perm-via-all]`. - **S53 gotcha #57 EXT BE — filter 3 Master Code unique indexes + Mig 46 local catch-up (Mig 47 `FilterMasterCatalogUniqueIndexesByIsDeleted`, index-only no-table):** Test-before RED→GREEN driven (test-specialist `MasterCatalogFilteredUniqueTests`, 3 FAIL on unfiltered → must turn GREEN). gotcha #57 4th+5th+6th cumulative (S45 Holiday Mig 43, S51 HRM×3 Mig 45 → now Department/Project/Supplier). Edit 3 config Code unique: `b.HasIndex(x=>x.Code).IsUnique()` → `+.HasFilter("[IsDeleted] = 0")`. **KEY: copied EXACT filter string byte-for-byte from HolidayConfiguration:18 + LeaveTypeConfiguration:19** (spaces around `=`, NOT guessed) → snapshot+SQL Server consistent with 13 existing filtered indexes. SupplierConfiguration: ONLY Code index filtered, `HasIndex(x=>x.Type)` :25 LEFT untouched (verified snapshot 3590 Type no-filter). Mig diff CLEAN: Up=3×DropIndex+3×CreateIndex filtered, Down=3×reverse unfiltered (no drift, no extra table/col). Master entities HAVE global `HasQueryFilter(!IsDeleted)` (unlike HRM) — app-check `db.X.AnyAsync(Code==req.Code)` filters soft-deleted → passed; then bare DB index counted it → UNIQUE violation 500. Filter aligns DB index with app-check. **Mig 46 local catch-up:** S52 left local LocalDB stuck at Mig 45 (prod had 46 via CI, local gap). `database update` to BOTH DBs applied Mig 46 (`AddSlaFieldsToItTicket`) THEN Mig 47 — residual closed. Dev override `--connection SolutionErp_Dev`; Design factory-default `SolutionErp_Design` (both `(localdb)\MSSQLLocalDB`). Build 0 err (2 pre-existing DocxRenderer warn). Full suite 203 GREEN (58 Domain + 145 Infra, +3 new). KHÔNG touch FE/test/commit (em main commits). Tag `[s53, gotcha-57-ext, mig47, mig46-catchup, filter-byte-for-byte]`. diff --git a/.claude/agent-memory/implementer-backend/pattern_controller_lower_authorize_handler_finegrained.md b/.claude/agent-memory/implementer-backend/pattern_controller_lower_authorize_handler_finegrained.md new file mode 100644 index 0000000..214b784 --- /dev/null +++ b/.claude/agent-memory/implementer-backend/pattern_controller_lower_authorize_handler_finegrained.md @@ -0,0 +1,18 @@ +--- +name: pattern-controller-lower-authorize-handler-finegrained +description: Controller hạ [Authorize] về any-auth → MediatR handler enforce fine-grained Admin-OR-dept membership; pairs with scoped-capability endpoint to avoid silent 403 +metadata: + type: feedback +--- + +Khi authz không phải role-tĩnh đơn (vd "Admin HOẶC thành viên 1 dept cụ thể"), KHÔNG nhồi vào `[Authorize(Roles=...)]` ở controller. Hạ controller về `[Authorize]` (any-authenticated) + enforce điều kiện fine-grained TRONG handler (query db dept membership → throw `ForbiddenException`). `[Authorize(Roles="Admin")]` chỉ cover Admin, KHÔNG cover "member của dept X" vì dept là data-driven, không phải role-name. + +**Why:** S54 ItTicket reassign — IT staff phải tự reassign được, nhưng "IT staff" = `User.DepartmentId == Department(Code=="IT").Id`, không tồn tại role-name "IT". `ICurrentUser` chỉ expose `Roles` + `UserId`, KHÔNG có `DepartmentId` → bắt buộc query `db.Users` lấy DepartmentId. Round-robin S52 đã dùng predicate `Departments.Where(d => d.Code=="IT" && !d.IsDeleted)` — TÁI DÙNG Y HỆT cho consistency. + +**How to apply:** +- Controller: `[Authorize]` ở class-level đủ; bỏ `[Authorize(Roles=...)]` ở action khi cần OR-of-(role, dept-membership). +- Handler: `isAdmin = cu.Roles.Contains("Admin")`; `myDeptId = db.Users.Where(Id==cu.UserId).Select(DepartmentId)`; check `!isAdmin && !(deptId is Guid m && myDeptId==m)` → `throw new ForbiddenException(msg)`. +- Siết thêm side-constraint cùng tầng: vd assignee phải thuộc dept IT → `ConflictException` (409, khác 403 caller-authz). +- Exceptions map qua GlobalExceptionMiddleware (Forbidden→403, Conflict→409, NotFound→404, Unauthorized→401) — KHÔNG try-catch controller. + +Linked: [[pattern-scoped-capability-endpoint-anti-silent-403]]. diff --git a/.claude/agent-memory/implementer-backend/pattern_scoped_capability_endpoint_anti_silent_403.md b/.claude/agent-memory/implementer-backend/pattern_scoped_capability_endpoint_anti_silent_403.md new file mode 100644 index 0000000..0cc2cb9 --- /dev/null +++ b/.claude/agent-memory/implementer-backend/pattern_scoped_capability_endpoint_anti_silent_403.md @@ -0,0 +1,18 @@ +--- +name: pattern-scoped-capability-endpoint-anti-silent-403 +description: Capability GET endpoint returns {canFlag:bool, list:[...]} so FE gates the action button BE-side; prevents user clicking then hitting silent 403 (gotcha #44) +metadata: + type: feedback +--- + +Khi action có authz fine-grained (handler enforce Admin-OR-dept), thêm 1 GET "capability" endpoint trả `{ CanReassign: bool, Staff: [...] }` (cờ + payload kèm). FE gate nút theo cờ BE-computed → user chỉ thấy/bấm được nút khi thực sự có quyền, tránh bấm rồi ăn 403 câm (gotcha #44 silent-403 UX). + +**Why:** S54 ItTicket — handler đã throw `ForbiddenException` đúng, nhưng nếu FE không biết trước thì user vẫn thấy nút "Đổi người xử lý", bấm → 403 khó hiểu. BE là source-of-truth quyền; FE-guard chỉ là UX layer → BE phải cấp cờ để FE gate. Cờ + list trả CHUNG 1 call (1 round-trip): non-authorized vẫn trả `canReassign:false` + `Staff = Array.Empty<...>()` (không leak danh sách NV). + +**How to apply:** +- Query record + 2 DTO: `AssignableStaffResult(bool CanReassign, IReadOnlyList Staff)`. Compute `canReassign` y hệt logic handler enforce (single source — đừng để lệch giữa capability-check và enforce-check). +- Endpoint `[HttpGet("assignable-staff")]` thừa hưởng class `[Authorize]` (any-auth) — đúng ý đồ (mọi user gọi được, cờ tự false nếu không quyền). +- DTO contract phải khớp FE đang build song song. Camel-case JSON out: `{ canReassign, staff: [{ id, fullName }] }`. Báo NGAY parent nếu đổi field — FE wire theo contract này. +- `Staff` scope = chỉ NV dept IT đang `IsActive`, `OrderBy(FullName)` (UI dropdown ổn định). + +Linked: [[pattern-controller-lower-authorize-handler-finegrained]]. diff --git a/.claude/agent-memory/implementer-frontend/MEMORY.md b/.claude/agent-memory/implementer-frontend/MEMORY.md index 9a4ace6..a7491d8 100644 --- a/.claude/agent-memory/implementer-frontend/MEMORY.md +++ b/.claude/agent-memory/implementer-frontend/MEMORY.md @@ -42,6 +42,7 @@ Dynamic class purged. PALETTE array full literal `as const` cycle `index % lengt ## 📅 Recent activity (last 10 FIFO) +- **2026-06-08 (S54 ItTicket reassign → CONVERGE 2 app, REVERSE S53 divergence) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** S53 đã tách fe-admin-only (admin reassign). S54 cho tổ IT tự reassign → cả 2 app cần nút. **Pattern mới: BE capability-flag gate thay vì FE đoán role.** Dropdown đổi `GET /users` → `GET /it-tickets/assignable-staff` trả `{canReassign:bool, staff:[{id,fullName}]}` (`[Authorize]` any-auth, KHÔNG 403 → chống gotcha #44). `canReassign = staffQ.data?.canReassign ?? false` (fetch on mount, KHÔNG `enabled`) → nút Pencil bọc `{canReassign && …}`. types/workflowApps.ts +`AssignableStaff`+`AssignableStaffResult` (mirror cả 2). fe-admin rewrite (bỏ UserOption/Paged) + fe-user full-add → **SHA256 IDENTICAL `4bcaf2f…`** (viết admin canonical → `cp` → verify; cùng `@/...` import + shadcn Dialog/Select/Button identical 2 app). Build PASS ×2 (0 TS err). **Gotcha (pre-existing, NOT từ change này):** types/workflowApps.ts 2 app NOT SHA-identical — fe-admin có `AttendanceReportDto` (P11-E) mà fe-user thiếu; 2 type S54 mirror đúng cả 2. Lesson: BE-computed capability flag = single-source → 2 app converge lại sau intentional divergence. - **2026-06-08 (S52 Task C+D-FE — ItTicket admin reassign + AttendanceReport menu, fe-admin ONLY):** Both intentional mirror-break (admin-only, NO fe-user touch, NO SHA256). **Task D-FE menu wiring:** Page+App.tsx route `/attendance/report` ALREADY exist (S52 prior). Only 2 of 4-place needed: (1) menuKeys.ts +OffAttendanceReport='Off_AttendanceReport' (mirror BE string exact, after OffChamCong) · (2) Layout.tsx staticMap +Off_AttendanceReport:'/attendance/report' (4th place gotcha #50). `types/menu.ts` = MenuNode tree type, key:string NOT typed-union → NO mirror there (resolvePath(key:string)). Leaf perm-gated via BE All[]→admin auto. **Task C reassign:** ItTicketsPage.tsx top-comment updated DIVERGES fe-user. Per-card Pencil button (cạnh 👤 assignee) → Dialog (size sm) + Select user. Users source = **GET /users {params:{page:1,pageSize:200}}→{items:UserOption{id,fullName,email}}** (reuse, proven PeWorkflows/Workflows/MeetingCalendar — `enabled:target!==null` lazy fetch). useMutation api.put(`/it-tickets/${id}/assign`,{assignedToUserId})→204 (NO json read)→invalidate['it-tickets']+toast.success+close. preselect t.assignedToUserId. UI deps: Dialog(open/onClose/title/children/footer?/size) + Select(native passthrough) + Button(variant=outline) + toast(sonner) + getErrorMessage(@/lib/apiError). Build PASS (0 err, 1945 mod). git: only 3 fe-admin file, fe-user untouched. - **2026-06-08 (P11-E Wave 1 — AttendanceReportPage fe-admin ONLY):** Report endpoint `[Authorize(Roles=Admin)]` → KHÔNG fe-user page → NO SHA256 mirror (intentional). 4 FE file: (1) types/workflowApps.ts +AttendanceReportRowDto{userId,fullName,departmentName?,daysPresent,totalWorkHours,otRaw,otWeekday,otWeekend,otHoliday,otWeighted}+AttendanceReportDto{year,month,rows,grandTotalWorkHours,grandTotalOtWeighted} (decimal→number) · (2) pages/office/AttendanceReportPage.tsx NEW: PageHeader+filter(Year Input number / Month Select 1-12 / Phòng ban Select fetch /departments) + TanStack key ['attendance-report',year,month,deptId] GET /attendances/report + Table 9 col STT/Họ tên/Phòng ban/Ngày công/Tổng giờ/OT thường/OT cuối tuần/OT lễ/OT quy đổi + tfoot Tổng(colSpan trick) + fmtNum vi-VN · (3) App.tsx import+route /attendance/report · (4) MyAttendancePage.tsx +button "Báo cáo" admin-only (user?.roles.includes('Admin')) navigate → DIVERGED fe-user (header comment cảnh báo). **Download Excel: `api.get(url,{params,responseType:'blob'})` (api instance inject JWT interceptor + refresh-retry — CHUẨN HƠN raw fetch spec gợi ý; proven ReportsPage/FormsPage/PeDetailTabs) → blob → createObjectURL → anchor.download.click → revoke. Filename content-disposition regex, fallback BaoCao-ChamCong-{Y}-{MM}.xlsx.** Build PASS (0 err, 1945 mod). KHÔNG menu key (button-reachable MVP). - **2026-06-08 (S51 P11-C — vehicles+drivers kind → HrmConfigsPage):** Declarative KIND_CONFIG +2 entry (10th proof). 4-place adapt (`:kind`-driven page → NO App.tsx route): types/hrm-config.ts (union +'vehicles'+'drivers' + VehicleDto/DriverDto + Create/Update inputs) · HrmConfigsPage.tsx (KIND_CONFIG +2, KINDS array +2, renderCells +2 branch before ot-policies fallback, import Car+IdCard) · menuKeys.ts (+Hrm_Config_Vehicles/Drivers — BE string exact) · Layout.tsx staticMap +2 BOTH app. Field keys: vehicles{code,name,licensePlate,seatCount,description} drivers{code,name,phoneNumber,licenseNumber,licenseClass,description}. cp admin→user 3 file SHA256 identical (page a3afd724, type 2c0775b3, menuKeys d650c086). Layout mirror tay (structural diff OK, 2 entry verified both). Build PASS ×2 (admin 1944mod, user 1934mod, 0 TS err). lucide IdCard EXISTS (no UserRound fallback). AMBIGUITY: BE catalog vehicles/drivers chưa tồn tại on-disk (Wave 1 parallel — implementer-backend đang/sẽ làm) → FE scaffold theo contract spec cấp; runtime cần BE `/hrm-configs/vehicles`+`/drivers` endpoint + Hrm_Config_Vehicles/Drivers const trong BE MenuKeys.cs + seed. diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index 167c895..cb50381 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -57,6 +57,8 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod ## 📅 Recent activity (FIFO — older → archive/git) +- **2026-06-08 (S54 ItTicket reassign authz Admin-OR-dept-IT cross-stack pre-commit — PASS, 0 blocker, gotcha #44 disarmed correctly):** Controller `/assign` hạ `[Authorize(Roles="Admin")]`→`[Authorize]` any-auth; authz moved INTO `AssignItTicketHandler` (Admin-OR-IT Forbidden + assignee-must-IT Conflict) + new `GetAssignableItStaffQuery` capability endpoint. **Independent re-verify GREEN:** `dotnet test SolutionErp.slnx` = **216 PASS** (58 Dom + 158 Infra, Failed:0 Skipped:0, +13 matches 203→216 claim exactly) · fe-admin + fe-user `tsc -p tsconfig.app.json --noEmit` BOTH exit 0 clean. **#2 CHÍ MẠNG role-string "Admin" CONFIRMED REAL (full chain traced):** `AppRoles.Admin="Admin"` literal (AppRoles.cs:5) → SeedRolesAsync `Name=roleName` (DbInit:1485) so DB Role.Name=="Admin" → Identity GetRolesAsync returns NAMES → JwtTokenService:32 `new Claim(ClaimTypes.Role, r)` → CurrentUserService:30-31 `FindAll(ClaimTypes.Role)` → `cu.Roles.Contains("Admin")` CORRECT. "QTV" (DbInit:1458 RoleLabels) = ShortName DISPLAY label only = decoy. Program.cs JWT sets NO `RoleClaimType` override (ClaimTypes.Role symmetric write/read). Same proven pattern as every existing Roles="Admin" endpoint. **Bypass airtight:** controller any-auth → handler sole gate; guard `if(!isAdmin && !(itDeptId is Guid mine && myDeptId==mine)) Forbidden` fail-CLOSED when itDeptId null (non-admin blocked; admin still passes role-branch). `Department.Code=="IT"` IS seeded (DbInit:2082 "Phòng CNTT") so live non-null. **Capability 0-leak:** non-auth → `{canReassign:false, staff:[]}` (no name leak), `[Authorize]` any-auth → 0 silent-403. **Defense-in-depth intact:** FE nút `{canReassign&&}` but PUT still hits handler guard → 403/409 → `onError: toast.error(getErrorMessage(err))` surfaces (NOT swallowed). **FE SHA256 page 4bcaf2f IDENTICAL both apps** (types.ts correctly NOT identical — admin AttendanceReportDto vs user HrDashboardDto diverge below; added AssignableStaff block byte-identical); old fe-user page was read-only kanban (NO app-specific logic lost — diff purely additive); fe-user has all imports (apiError.getErrorMessage, Dialog size/footer/onClose props match, Select passthrough, sonner). **Test 13-fact NOT happy-path:** Case5 Forbidden side-effect assert `AssignedToUserId.Should().BeNull()` (red-able by contrast vs Case6/7 same-handler success), Case3/3b empty-staff 0-leak, Case8 Conflict msg-exact. prove-by-contrast ĐỦ CHẶT (partition non-IT-throws vs IT/admin-succeed identical handler). **1 MINOR defer:** assignee-must-IT NEW vs old handler (git HEAD: old allowed admin→ANY active user); itDeptId-null → even admin Conflict — fail-closed acceptable+spec-requested, cosmetic (prod IT-dept seeded). **Learned:** authz role-string review = trace FULL chain const→seed Name→GetRolesAsync(names not codes)→Claim(ClaimTypes.Role)→reader AND grep JWT cfg for `RoleClaimType` override (none=symmetric) — display-code (QTV) in RoleLabels/ShortName dict = classic decoy. **surprise:** moving authz controller→handler is the CORRECT gotcha #44 fix (not a smell) when paired with BE-computed capability flag for FE gating + handler as sole gate. Verdict PASS — safe commit. Tag [s54, it-ticket-reassign-authz, gotcha44-disarmed, role-string-chain-verified, cross-stack-clean]. + - **2026-06-08 (S52-late Task C ItTicket admin reassign + Task D AttendanceReport menu-key pre-commit — PASS, 0 blocker):** Migration-FREE (menu = idempotent DbInitializer seed). 5 prod files: MenuKeys.cs (const+All[]), DbInitializer.cs (1 seed tuple), fe-admin {menuKeys.ts, Layout.tsx staticMap, ItTicketsPage.tsx}. **Independent re-verify GREEN:** `dotnet build SolutionErp.slnx` 0-warn/0-err · `npm run build` fe-admin tsc-b+vite OK (1945 modules, only pre-existing CSS @import + >500KB + ineffective-dynamic-import warns). **menuKeyMatchOk:** `"Off_AttendanceReport"` byte-identical 4 places (MenuKeys.cs:125 const == menuKeys.ts:68 == seed parent key == Layout staticMap:87); seed leaf parent=`MenuKeys.Off` order=8 icon=`FileBarChart` (verified valid lucide alias `FileChartColumn as FileBarChart` — getIcon resolves via Icons[name]); App.tsx route `/attendance/report → AttendanceReportPage` PRE-EXISTING committed S52 (6a66429, no diff, 170-LOC real page not stub) — Layout maps to REAL route. `types/menu.ts` correctly NOT mirrored (`key:string` not typed union). admin auto-perm via `SeedAdminPermissionsAsync` iterating `MenuKeys.All` (DbInit:1917, idempotent Contains:1919); new-leaf-on-existing-DB confirmed (upsert loop:1845-1862 TryGetValue-miss→Add). **reassignCorrect:** FE PUT `/it-tickets/{id}/assign` body `{assignedToUserId}` MATCHES BE record `AssignItTicketBody(Guid AssignedToUserId)` (ItTicketsController:42); endpoint `[Authorize(Roles="Admin")]` (:34) under class `[Authorize]` — admin-app FE calling = correct; user-list reuses EXISTING `/users` GET (`PagedResult` items{id,fullName,email}, no new BE endpoint, lazy `enabled:target!==null`); 204 NoContent handled (no body-parse); `invalidateQueries(['it-tickets'])` on success; handler sets BOTH AssignedToUserId+AssignedToFullName (WorkflowAppsFeatures:467-468) + validates assignee IsActive→NotFound, no try-catch (GlobalExceptionMiddleware). **feUserUnchanged:** `git diff -- fe-user/` EMPTY (Task C = fe-admin-only divergence, documented top-comment ItTicketsPage:3-5). **noScopeCreep:** git status prod = EXACTLY the 5 expected files, agent-memory noise ignored, no new migration, no BE beyond MenuKeys+DbInitializer, ItTicketsPage diff 98+/5- all Task-C-scoped (imports+state+mutation+Pencil-btn+Dialog), 0 mock/alert markers. **Learned:** menu-key wiring = verify byte-identity across the FULL mirror set (BE const + BE All[] + seed parent + FE menuKeys + FE staticMap) + confirm the target route actually EXISTS (grep App.tsx) — a staticMap entry pointing to a non-existent route silently drops the leaf (gotcha #50). **surprise:** lucide `FileBarChart` is a deprecated-alias (re-exported from FileChartColumn) but still valid — d.ts grep confirmed before flagging. Verdict PASS — safe to commit. Tag [s52-late, it-ticket-reassign, attendance-report-menukey, menukey-mirror-5way, gotcha44-disarmed, gotcha50-disarmed]. - **2026-06-08 (S53 gotcha #57 EXT Mig 47 — Master catalog filtered-unique pre-commit — PASS, 0 blocker, Smart Friend clean):** 4th/5th/6th cumulative gotcha #57 (after Holiday Mig 43 S45 + HRM ×3 Mig 45 S51). 3 Master configs (Department:18/Project:19/Supplier:24) Code unique index `.IsUnique()` → `+.HasFilter("[IsDeleted] = 0")` + Mig 47 (3-file) + 3 new tests. **Independent re-verify ALL GREEN:** build SolutionErp.slnx 0-warn/0-err · **full suite 203 PASS** (58 Dom + 145 Infra, Failed:0 Skipped:0, +3) · 3 new tests run isolated 3-passed-0-skipped. **Cat correctness:** filter string byte-identical to HolidayConfiguration:18 (xxd `5b 49 73 44 65 6c 65 74 65 64 5d 20 3d 20 30` — spaces around `=`, not guessed); index STAYS `unique:true` (2 active same-Code still violate — active uniqueness preserved); Supplier Type index (:25) UNTOUCHED non-unique unfiltered (snapshot:3590 bare). Mig Up=3×Drop+3×Create-filtered, Down=3×reverse-unfiltered (reversible). Snapshot+Designer both show filter on all 3 Master Code idx. **Test NOT tautology:** seeds IsDeleted=true row → real Create*CommandHandler (app-check `AnyAsync(Code==req.Code)` thru HasQueryFilter !IsDeleted PASSES) → asserts NotThrow + active-count==1 + IgnoreQueryFilters all==2; RED-before confirmed (3 failed SqliteException UNIQUE on unfiltered). Cmd signatures match test calls (Project 7-arg/Supplier 9-arg/Dept 4-arg). **noScopeCreep:** git status = exactly 3 configs + snapshot + Mig47 (2 untracked) + 1 test + 2 MEMORY; no FE, no extra mig, no stray. Mig 47 latest in seq (after Mig46 ItTicket SLA). **Learned:** cookie-cutter EXT of proven pattern → discriminator = byte-compare filter string (xxd) vs canonical sibling + verify index still unique (filter must NARROW scope not DROP uniqueness). app-level dup-check existence = the test premise; verify handler actually has `AnyAsync(Code)` else test premise false. **surprise:** implementer claimed "2 pre-existing DocxRenderer warnings" but clean incremental rebuild = 0 warn (unrelated, non-issue). Verdict PASS — safe commit. Tag [s53, gotcha57-ext, mig47, master-catalog, smart-friend-clean]. diff --git a/.claude/agent-memory/test-specialist/MEMORY.md b/.claude/agent-memory/test-specialist/MEMORY.md index 3d12768..424e9b0 100644 --- a/.claude/agent-memory/test-specialist/MEMORY.md +++ b/.claude/agent-memory/test-specialist/MEMORY.md @@ -53,6 +53,7 @@ Test theo CODE (single source truth), document mismatch header comment + report. ## 📅 Recent activity (last 10 FIFO) +- **2026-06-08 (S54 ItTicket reassign authz — test-before-merge SECURITY) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** +13 test `tests/.../Application/ItTicketReassignAuthzTests.cs` → **203→216 PASS** (58 Domain + Infra 145→158, 0 fail). **GetAssignableItStaff (6):** Admin→CanReassign=true + 2 IT-active ordered FullName (Cao = { items: T[] } - function formatSlaDue(iso: string): string { return new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', @@ -39,12 +34,14 @@ export function ItTicketsPage() { const [target, setTarget] = useState(null) const [pick, setPick] = useState('') - // Admin user list (reuse endpoint sẵn có — KHÔNG thêm BE endpoint). - const users = useQuery({ - queryKey: ['users'], - queryFn: async () => (await api.get>('/users', { params: { page: 1, pageSize: 200 } })).data.items, - enabled: target !== null, // chỉ fetch khi mở dialog + // BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút + // trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[]. + const staffQ = useQuery({ + queryKey: ['it-tickets', 'assignable-staff'], + queryFn: async () => (await api.get('/it-tickets/assignable-staff')).data, }) + const canReassign = staffQ.data?.canReassign ?? false + const staff = staffQ.data?.staff ?? [] const reassign = useMutation({ // 204 NoContent — không đọc JSON body. @@ -107,16 +104,17 @@ export function ItTicketsPage() { 👤 {t.assignedToFullName ?? Chưa giao} - {/* Admin-only reassign (BE PUT /assign gác [Authorize(Admin)]). - Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */} - + {/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */} + {canReassign && ( + + )} {t.slaDueAt && ( )} - {/* Reassign Dialog — Admin only (BE gác quyền). Select danh sách user. */} + {/* Reassign Dialog — Admin OR tổ IT (BE gác quyền + assignee phải thuộc tổ IT). Select danh sách /assignable-staff. */}
- setPick(e.target.value)} disabled={staffQ.isLoading}> - {(users.data ?? []).map(u => ( + {staff.map(u => ( ))} - {users.isLoading &&
Đang tải danh sách…
} + {staffQ.isLoading &&
Đang tải danh sách…
}
)} diff --git a/fe-admin/src/types/workflowApps.ts b/fe-admin/src/types/workflowApps.ts index 715c03b..0e99121 100644 --- a/fe-admin/src/types/workflowApps.ts +++ b/fe-admin/src/types/workflowApps.ts @@ -115,6 +115,9 @@ export interface OtRequestDto { id: string; maDonTu: string | null; requesterUse export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string; slaDueAt: string | null; slaBreached: boolean } +// S54 — reassign capability flag từ BE GET /it-tickets/assignable-staff (Admin OR tổ IT). canReassign=false + staff=[] cho user thường. +export type AssignableStaff = { id: string; fullName: string } +export type AssignableStaffResult = { canReassign: boolean; staff: AssignableStaff[] } export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null } // P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi (admin-only). Mirror BE AttendanceReportDto/RowDto (decimal → number). diff --git a/fe-user/src/pages/office/ItTicketsPage.tsx b/fe-user/src/pages/office/ItTicketsPage.tsx index 4e0a653..6841581 100644 --- a/fe-user/src/pages/office/ItTicketsPage.tsx +++ b/fe-user/src/pages/office/ItTicketsPage.tsx @@ -1,14 +1,20 @@ // Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52). // Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn). -// File MIRROR SHA256 identical fe-user counterpart. -import { useQuery } from '@tanstack/react-query' -import { Ticket } from 'lucide-react' +// S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic. +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Pencil, Ticket } from 'lucide-react' +import { toast } from 'sonner' import { PageHeader } from '@/components/PageHeader' +import { Button } from '@/components/ui/Button' +import { Dialog } from '@/components/ui/Dialog' +import { Select } from '@/components/ui/Select' import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { IT_TICKET_CATEGORY_LABELS, IT_TICKET_PRIORITY_BADGE, IT_TICKET_PRIORITY_LABELS, - IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult, + IT_TICKET_STATUS_LABELS, type AssignableStaffResult, type ItTicketDto, type PagedResult, } from '@/types/workflowApps' function formatSlaDue(iso: string): string { @@ -18,11 +24,46 @@ function formatSlaDue(iso: string): string { } export function ItTicketsPage() { + const qc = useQueryClient() const list = useQuery({ queryKey: ['it-tickets'], queryFn: async () => (await api.get>('/it-tickets', { params: { pageSize: 100 } })).data, }) + // Reassign Dialog state. `target` = ticket đang gán lại; `pick` = userId đã chọn. + const [target, setTarget] = useState(null) + const [pick, setPick] = useState('') + + // BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút + // trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[]. + const staffQ = useQuery({ + queryKey: ['it-tickets', 'assignable-staff'], + queryFn: async () => (await api.get('/it-tickets/assignable-staff')).data, + }) + const canReassign = staffQ.data?.canReassign ?? false + const staff = staffQ.data?.staff ?? [] + + const reassign = useMutation({ + // 204 NoContent — không đọc JSON body. + mutationFn: (input: { id: string; assignedToUserId: string }) => + api.put(`/it-tickets/${input.id}/assign`, { assignedToUserId: input.assignedToUserId }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['it-tickets'] }) + toast.success('Đã gán lại người xử lý') + closeDialog() + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function openDialog(t: ItTicketDto) { + setTarget(t) + setPick(t.assignedToUserId ?? '') // preselect người xử lý hiện tại + } + function closeDialog() { + setTarget(null) + setPick('') + } + const items = list.data?.items ?? [] // Group by status for kanban-ish display @@ -59,8 +100,21 @@ export function ItTicketsPage() { {IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
- - 👤 {t.assignedToFullName ?? Chưa giao} + + + 👤 {t.assignedToFullName ?? Chưa giao} + + {/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */} + {canReassign && ( + + )} {t.slaDueAt && ( )} + + {/* Reassign Dialog — Admin OR tổ IT (BE gác quyền + assignee phải thuộc tổ IT). Select danh sách /assignable-staff. */} + + + + + } + > + {target && ( +
+
+ Ticket {target.maTicket ?? '—'} · {target.title} +
+
+ + + {staffQ.isLoading &&
Đang tải danh sách…
} +
+
+ )} +
) } diff --git a/fe-user/src/types/workflowApps.ts b/fe-user/src/types/workflowApps.ts index de16792..6783768 100644 --- a/fe-user/src/types/workflowApps.ts +++ b/fe-user/src/types/workflowApps.ts @@ -115,5 +115,8 @@ export interface OtRequestDto { id: string; maDonTu: string | null; requesterUse export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string } export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string; slaDueAt: string | null; slaBreached: boolean } +// S54 — reassign capability flag từ BE GET /it-tickets/assignable-staff (Admin OR tổ IT). canReassign=false + staff=[] cho user thường. +export type AssignableStaff = { id: string; fullName: string } +export type AssignableStaffResult = { canReassign: boolean; staff: AssignableStaff[] } export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null } export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number } diff --git a/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs b/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs index 0f7b143..80c8e30 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ItTicketsController.cs @@ -29,15 +29,20 @@ public class ItTicketsController(IMediator mediator) : ControllerBase return NoContent(); } - // P11-D: admin re-assign ticket cho IT staff (override round-robin auto-assign). + // P11-D + S54: re-assign ticket cho IT staff. Authz Admin-OR-dept-IT enforce trong handler + // (controller [Authorize] any-auth — gotcha #44-aware: pipeline mở, handler check fine-grained). [HttpPut("{id:guid}/assign")] - [Authorize(Roles = "Admin")] public async Task Assign(Guid id, [FromBody] AssignItTicketBody body) { await mediator.Send(new AssignItTicketCommand(id, body.AssignedToUserId)); return NoContent(); } + // S54: danh sách IT staff nhận ticket + cờ canReassign (FE gate nút). [Authorize] any-auth. + [HttpGet("assignable-staff")] + public async Task AssignableStaff() + => Ok(await mediator.Send(new GetAssignableItStaffQuery())); + public record UpdateItTicketStatusBody(int Status, string? Resolution); public record AssignItTicketBody(Guid AssignedToUserId); } diff --git a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs index 88a4812..13480c1 100644 --- a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs +++ b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs @@ -440,6 +440,40 @@ public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser } } +// S54: danh sách nhân viên tổ IT có thể nhận ticket + cờ canReassign (Admin HOẶC thành viên dept IT). +// FE gate nút "Đổi người xử lý" theo CanReassign (BE-computed, tránh silent 403 gotcha #44). +public record AssignableStaffDto(Guid Id, string FullName); +public record AssignableStaffResult(bool CanReassign, IReadOnlyList Staff); +public record GetAssignableItStaffQuery : IRequest; + +public class GetAssignableItStaffHandler(IApplicationDbContext db, ICurrentUser cu) + : IRequestHandler +{ + public async Task Handle(GetAssignableItStaffQuery q, CancellationToken ct) + { + if (cu.UserId is null) throw new UnauthorizedException(); + var itDeptId = await db.Departments.AsNoTracking() + .Where(d => d.Code == "IT" && !d.IsDeleted) + .Select(d => (Guid?)d.Id) + .FirstOrDefaultAsync(ct); + var isAdmin = cu.Roles.Contains("Admin"); + var myDeptId = await db.Users.AsNoTracking() + .Where(u => u.Id == cu.UserId.Value) + .Select(u => u.DepartmentId) + .FirstOrDefaultAsync(ct); + var isItStaff = itDeptId is Guid d && myDeptId == d; + var canReassign = isAdmin || isItStaff; + if (!canReassign || itDeptId is not Guid deptId) + return new AssignableStaffResult(canReassign, Array.Empty()); + var staff = await db.Users.AsNoTracking() + .Where(u => u.DepartmentId == deptId && u.IsActive) + .OrderBy(u => u.FullName) + .Select(u => new AssignableStaffDto(u.Id, u.FullName)) + .ToListAsync(ct); + return new AssignableStaffResult(canReassign, staff); + } +} + // P11-D: admin re-assign ticket cho IT staff cụ thể (override round-robin auto-assign // lúc Create). Denorm AssignedToFullName từ User.FullName tại thời điểm gán. public record AssignItTicketCommand(Guid Id, Guid AssignedToUserId) : IRequest; @@ -461,9 +495,24 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID if (cu.UserId is null) throw new UnauthorizedException(); var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct); if (t is null) throw new NotFoundException("ItTicket", req.Id); + + // S54: authz — chỉ Admin HOẶC thành viên tổ IT mới reassign (controller đã hạ [Authorize] any-auth). + var itDeptId = await db.Departments.AsNoTracking() + .Where(d => d.Code == "IT" && !d.IsDeleted) + .Select(d => (Guid?)d.Id) + .FirstOrDefaultAsync(ct); + var isAdmin = cu.Roles.Contains("Admin"); + var myDeptId = await db.Users.AsNoTracking() + .Where(u => u.Id == cu.UserId.Value).Select(u => u.DepartmentId).FirstOrDefaultAsync(ct); + if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine)) + throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket."); + var assignee = await db.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct); if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId); + // S54: assignee bắt buộc thuộc tổ IT (khớp dropdown scoped + round-robin). + if (!(itDeptId is Guid itd && assignee.DepartmentId == itd)) + throw new ConflictException("Người được giao phải thuộc tổ IT."); t.AssignedToUserId = assignee.Id; t.AssignedToFullName = assignee.FullName; t.UpdatedAt = clock.UtcNow; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketReassignAuthzTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketReassignAuthzTests.cs new file mode 100644 index 0000000..406c592 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketReassignAuthzTests.cs @@ -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(); + 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() }; + + private static async Task 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 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()); + await fix.CreateUserAsync("it-cao@test.local", "Cao", itDeptId, Array.Empty()); + // Noise: KT user + IT inactive — KHÔNG được xuất hiện. + await fix.CreateUserAsync("kt-1@test.local", "Ke Toan", ktDeptId, Array.Empty()); + var inactiveIt = await fix.CreateUserAsync("it-off@test.local", "IT Nghi", itDeptId, Array.Empty()); + 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()); + await fix.CreateUserAsync("it-binh@test.local", "Binh", itDeptId, Array.Empty()); + + // 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()); + var caller = await fix.CreateUserAsync("kt-caller@test.local", "Ke Toan Vien", ktDeptId, Array.Empty()); + + 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()); + var caller = await fix.CreateUserAsync("nodept@test.local", "Khong Phong", null, Array.Empty()); + + 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()); + var inactive = await fix.CreateUserAsync("it-dead@test.local", "Inactive Mem", itDeptId, Array.Empty()); + 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(); + } + } + + // ===================================================================== + // 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()); + var caller = await fix.CreateUserAsync("kt-bad@test.local", "Ke Toan", ktDeptId, Array.Empty()); + 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("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()); + 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()); + var assignee = await fix.CreateUserAsync("it-peer@test.local", "IT Peer", itDeptId, Array.Empty()); + 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()); + 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() + .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()); + 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("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(); + } + } + + // 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(); + } + } +}