[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:
@ -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]`.
|
||||
|
||||
@ -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]].
|
||||
@ -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<XDto> 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]].
|
||||
@ -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<T>) + 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.
|
||||
|
||||
@ -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<UserDto>` 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].
|
||||
|
||||
@ -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<Truong) no KT/inactive leak · IT-staff→true · non-IT non-admin (KT)→false + **empty staff (0-leak assert)** · dept-null→false+empty · inactive-IT-excluded · UserId null→Unauthorized. **AssignItTicket (7):** non-IT non-admin→**ForbiddenException** + side-effect `AssignedToUserId.Should().BeNull()` (no-mutation) · Admin+assignee∈IT→success · IT-staff+assignee∈IT→success · assignee∉IT(KT)→**ConflictException** "Người được giao phải thuộc tổ IT." · assignee inactive→NotFound · ticket not found→NotFound · null→Unauthorized. **Pattern mới: authz-capability test = seed 2-dept (IT+KT) + fake `ICurrentUser` role/dept matrix; assert canReassign flag + Forbidden/Conflict guard; empty-staff = 0-leak.** Forbidden red-able **by-contrast** (case5 non-IT vs case7 IT-staff identical-setup → chỉ khác caller-identity; rule cấm sửa prod để chứng minh RED). **No prod bug** — handler-level data-dependent authz (caller-dept vs IT-dept) = CORRECT pattern, KHÔNG phải gotcha #44 silent-403 gap (Pattern 10 reflection-regression chỉ cho static `[Authorize(Policy)]`; data-driven authz PHẢI ở handler = enforcement point, test cover tại đó). Tag [s54, it-ticket-reassign, authz-capability, forbidden-conflict-guard, test-before-merge, 0-leak].
|
||||
- **2026-06-08 (S52 P11-D Master gotcha #57 EXT) [test-before · 3 RED LIVE]:** +3 test `tests/.../Application/MasterCatalogFilteredUniqueTests.cs` (run `--filter MasterCatalogFilteredUnique` → Failed 3/Passed 0). Department+Project+Supplier `.IsUnique()` BARE (Dept cfg:18 / Proj:19 / Supp:24) chưa `[IsDeleted]=0` — cùng class gotcha #57. Mirror EXACT GROUP B HrmConfigFilteredUniqueTests: seed row `IsDeleted=true` slot Code="DUP1" → `Create{Dept|Project|Supplier}CommandHandler(db)` cùng Code → assert `NotThrowAsync` + active==1 + `IgnoreQueryFilters` all==2. **3 RED** = `DbUpdateException → SQLite Error 19 UNIQUE constraint failed: {Departments|Projects|Suppliers}.Code` (app-check `AnyAsync(Code==X)` chạy QUA HasQueryFilter → loại soft-deleted → PASS → Add+SaveChanges → DB UNIQUE bare đếm cả row xoá → throw). NOT test lỗi — REPORTED em main fix migration `.HasFilter` 3 config → flip GREEN. **⚠️ all-count PHẢI `IgnoreQueryFilters()`** (khác HRM ref dùng raw `Count(Code==X)` trên DbSet đã có HasQueryFilter → trả 1 not 2 = sai; tôi sửa = active-count plain DbSet, all-count IgnoreQueryFilters). 3 handler clean `(IApplicationDbContext db)` 1-dep. KHÔNG đụng Configuration/Domain/migration. Tag [s52, p11-d, gotcha-57, master-catalog, filtered-unique, test-before, RED].
|
||||
- **2026-06-08 (S52 P11-D Wave2 round-robin + SLA-due) [proxy by em main: agent killed session-limit trước MEMORY step]:** +9 test `ItTicketAssignSlaTests.cs` → **200 PASS** (Infra 133→142). **Round-robin:** seed Department Code="IT" + 2 user A/B `IsActive` trong IT + A có 1 ticket Open → Create → assign **B** (load 0<1); tie A=B → `ThenBy(Id)`; edge no-dept-IT / no-user-IT → unassigned; user ngoài IT hoặc `IsActive=false` KHÔNG assign. **SLA-due:** Priority Urgent→+4h / High→+8h / Medium→+24h / Low→+72h (assert `e.SlaDueAt==CreatedAt+SlaWindow[priority]`). **Regression P11-F:** create vẫn gen `^IT/\d{4}/\d{3}$`. `ItTicketSlaJob` BackgroundService SKIP unit-test (breach-query inline, khó test trực tiếp — REPORTED). Baseline 191→**200** (58 Domain + 142 Infra). Tag [s52, p11-d, round-robin, sla-due, regression].
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
// 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).
|
||||
// ⚠️ DIVERGES fe-user (KHÔNG còn SHA256 identical, S52 Task C): fe-admin thêm
|
||||
// nút "Đổi" + Dialog gán lại người xử lý (PUT /it-tickets/{id}/assign, Admin-only).
|
||||
// fe-user giữ bản read-only — KHÔNG mirror admin reassign sang đó.
|
||||
// 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'
|
||||
@ -16,12 +14,9 @@ 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'
|
||||
|
||||
type UserOption = { id: string; fullName: string; email: string }
|
||||
type Paged<T> = { 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<ItTicketDto | null>(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<Paged<UserOption>>('/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<AssignableStaffResult>('/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,8 +104,8 @@ export function ItTicketsPage() {
|
||||
<span className="truncate" title={t.assignedToFullName ?? undefined}>
|
||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDialog(t)}
|
||||
@ -117,6 +114,7 @@ export function ItTicketsPage() {
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{t.slaDueAt && (
|
||||
<span
|
||||
@ -144,7 +142,7 @@ export function ItTicketsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<Dialog
|
||||
open={target !== null}
|
||||
onClose={closeDialog}
|
||||
@ -169,13 +167,13 @@ export function ItTicketsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">Người xử lý</label>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={users.isLoading}>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
|
||||
<option value="">— Chọn người xử lý —</option>
|
||||
{(users.data ?? []).map(u => (
|
||||
{staff.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.fullName}</option>
|
||||
))}
|
||||
</Select>
|
||||
{users.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách…</div>}
|
||||
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách…</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
|
||||
})
|
||||
|
||||
// Reassign Dialog state. `target` = ticket đang gán lại; `pick` = userId đã chọn.
|
||||
const [target, setTarget] = useState<ItTicketDto | null>(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<AssignableStaffResult>('/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,9 +100,22 @@ export function ItTicketsPage() {
|
||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||
<span className="text-muted-foreground truncate" title={t.assignedToFullName ?? undefined}>
|
||||
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
|
||||
<span className="truncate" title={t.assignedToFullName ?? undefined}>
|
||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
||||
</span>
|
||||
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
|
||||
{canReassign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDialog(t)}
|
||||
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Đổi người xử lý"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{t.slaDueAt && (
|
||||
<span
|
||||
className={cn(
|
||||
@ -87,6 +141,43 @@ export function ItTicketsPage() {
|
||||
Chưa có ticket nào.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reassign Dialog — Admin OR tổ IT (BE gác quyền + assignee phải thuộc tổ IT). Select danh sách /assignable-staff. */}
|
||||
<Dialog
|
||||
open={target !== null}
|
||||
onClose={closeDialog}
|
||||
title="Gán lại người xử lý"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeDialog}>Hủy</Button>
|
||||
<Button
|
||||
onClick={() => target && pick && reassign.mutate({ id: target.id, assignedToUserId: pick })}
|
||||
disabled={!pick || reassign.isPending}
|
||||
>
|
||||
{reassign.isPending ? 'Đang lưu…' : 'Lưu'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{target && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ticket <span className="font-mono">{target.maTicket ?? '—'}</span> · {target.title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">Người xử lý</label>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
|
||||
<option value="">— Chọn người xử lý —</option>
|
||||
{staff.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.fullName}</option>
|
||||
))}
|
||||
</Select>
|
||||
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách…</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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<IActionResult> 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<IActionResult> AssignableStaff()
|
||||
=> Ok(await mediator.Send(new GetAssignableItStaffQuery()));
|
||||
|
||||
public record UpdateItTicketStatusBody(int Status, string? Resolution);
|
||||
public record AssignItTicketBody(Guid AssignedToUserId);
|
||||
}
|
||||
|
||||
@ -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<AssignableStaffDto> Staff);
|
||||
public record GetAssignableItStaffQuery : IRequest<AssignableStaffResult>;
|
||||
|
||||
public class GetAssignableItStaffHandler(IApplicationDbContext db, ICurrentUser cu)
|
||||
: IRequestHandler<GetAssignableItStaffQuery, AssignableStaffResult>
|
||||
{
|
||||
public async Task<AssignableStaffResult> 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<AssignableStaffDto>());
|
||||
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;
|
||||
|
||||
@ -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