18 KiB
sub-reviewer-2 — Adversarial review W6 (view-guard + inbox + guard TRÌNH của Contract)
Ghi TỪNG TRỤC ngay khi đo xong (chống #53). READ-only — không sửa gì. Diff đo:
git diffworking-tree vs HEADfe1f6b0—ContractFeatures.cs(+142/-4) ·ContractWorkflowService.cs(+15/-1) ·tests/.../Application/ContractV2PipelineTests.cs(UNTRACKED, mới).
TRỤC 1 — Nới-view có mở QUÁ không? (per-HĐ vs role/workflow-blanket)
1.1 Ba call-site GetEligiblePhases — ĐỦ và ĐÚNG VỊ TRÍ
Grep repo-wide (grep -rn "GetEligiblePhases" src/Backend --include=*.cs) → đúng 3 call-site,
khớp 3 anchor spec (số dòng đã trôi sau khi chèn code):
| Spec anchor | Dòng THẬT sau diff | Handler | W6 có ghép vế V2? |
|---|---|---|---|
:301 |
ContractFeatures.cs:301 |
ListContractsQueryHandler |
CÓ (:307-314) |
:381 |
ContractFeatures.cs:425 |
ListDeletedContractsQueryHandler |
KHÔNG (cố ý, :426-431) |
:486 |
ContractFeatures.cs:603 |
GetContractQueryHandler |
CÓ (:605-621) |
Không có call-site thứ 4. Rủi ro "SỬA CHỈ 1/3 call-site" (spec §③-C) = KHÔNG xảy ra.
1.2 Vế V2 là PER-HĐ thật — KHÔNG role-blanket
ContractFeatures.cs:311-312 (List):
|| (x.c.Phase != ContractPhase.DangSoanThao
&& myWorkflowIds.Contains(x.c.ApprovalWorkflowId))
ContractFeatures.cs:609-613 (Detail): AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck && l.ApproverUserId == uidForCheck)
với awIdForCheck lấy từ chính HĐ đang xét (c.ApprovalWorkflowId).
⇒ Vị ngữ hỏi theo workflow ĐÃ PIN của HĐ đó, không hỏi theo role. Approver của quy trình KHÁC
vẫn 403 — test T3 :356-360 ghim đúng ca này. KHÔNG role-blanket. ĐẠT.
Hai bờ List ⟂ Detail đồng vị ngữ (kiểm bằng tay, khai triển 2 biểu thức):
- List cho thấy ⟺
isDrafter ∨ eligibleByRole ∨ (Phase≠Nháp ∧ inMyWorkflows) ∨ (hardCopy ∧ Phase=DaPhatHanh) - Detail cho mở ⟺
isDrafter ∨ eligibleByRole ∨ (hardCopy ∧ Phase=DaPhatHanh) ∨ (¬Nháp ∧ isV2Approver)⇒ TƯƠNG ĐƯƠNG. Không có lớp "thấy ở list mà 403 khi mở" (hoặc ngược lại).
1.3 Nhưng NÓ LÀ workflow-blanket (theo thiết kế spec) — cần owner biết ĐỘ RỘNG
ApprovalWorkflow là khuôn dùng lại (Code + Version + ApplicableType=Contract + IsActive),
1 quy trình pin cho NHIỀU HĐ. Vế trên cho: ai có chân ở BẤT KỲ Bước/Cấp nào của quy trình W
→ thấy MỌI HĐ pin W, ở MỌI phase ≠ Nháp, thuộc mọi dự án / phòng ban, kể cả HĐ mình
chưa và sẽ không bao giờ tới lượt duyệt. Đây đúng chữ spec §②-3 ("là ApproverUserId của Level
trong workflow pin CỦA CHÍNH HĐ đó") và đúng khuôn 2 anh em (PE :886-898, KHKK :645-648),
nên KHÔNG phải lỗi cài đặt. Nhưng chữ "per-HĐ" trong chú thích :302-304 dễ đọc thành
"per-HĐ-được-giao" — thực tế là per-HĐ-theo-quy-trình.
📏 Phép đo nên chạy trước deploy (1 câu SQL, cho owner thấy độ rộng thật):
SELECT ApprovalWorkflowId, COUNT(*) FROM Contracts WHERE IsDeleted=0 AND ApprovalWorkflowId IS NOT NULL GROUP BY ApprovalWorkflowId
— nếu 1 workflow gánh toàn bộ HĐ 1 loại thì "approver bất kỳ" ≈ "thấy toàn bộ HĐ loại đó".
→ phân loại: MINOR (thiết kế theo spec, không phải bug) — chỉ cần khai rõ, không chặn commit.
1.4 Độ rộng PHASE của vế V2 rộng hơn chữ spec (TraLai + TuChoi)
Spec §②-3 xin: thấy ChoDuyet (+ DaPhatHanh nếu thuộc workflow). Cài đặt dùng
phủ định Phase != DangSoanThao ⇒ approver còn thấy TraLai(98) và TuChoi(99)
(và mọi phase legacy 1,3-8 nếu data cũ có).
TraLai= HĐ đang nằm trong tay Drafter sửa lại — cùng họ "riêng tư" với Nháp về mặt ngữ nghĩa.TuChoi= terminal khoá. Khuôn KHKKContractSigningPlanFeatures.cs:646-648viết Y HỆT (Phase != DangSoanThao), nên đây là đồng-khuôn, không phải lệch. Ghi nhận để lần sau ai siết thì biết đang đổi hợp đồng. → MINOR (đồng khuôn anh em, spec không cấm).
1.5 Vế HardCopyActorRoles ĐÚNG LÀ role-blanket — nhưng spec gọi tên đích danh
:375-379 + :314 + :618-619: mọi user mang Director ∨ AuthorizedSigner ∨ HrAdmin ∨ CostControl
thấy TOÀN BỘ HĐ DaPhatHanh của công ty (có GiaTri), không cần liên quan dự án/quy trình.
Spec §②-3 liệt đúng 4 vai này ⇒ đúng đơn hàng. Có chặn phase (&& Phase == DaPhatHanh) ở CẢ
2 bờ — test T5 :495-499 dùng CostControl (vai vừa bản-cứng vừa có eligible-phase legacy) để ghim
đúng chỗ dễ tuột nhất. ĐẠT.
1.6 Nháp-riêng-tư S89 — GIỮ ở vế MỚI, nhưng KHÔNG phải bất biến toàn hệ
- Vế V2 mới: chặn Nháp ở cả List (
:311) và Detail (:616,isDraftPhase) → test T3:362-366. ĐẠT. - 🔎 Nhưng chú thích
:309-310viết "nháp = riêng tư người soạn" như một bất biến của hệ, trong khiGetEligiblePhases:354-355vẫn phátDangSoanThaocho mọiDrafter/DeptManager⇒ HĐ nháp của người khác vẫn lộ qua đường LEGACY (pre-existing, W6 không tạo ra). Test T1:233-234khai thẳng điều này (trung thực) — nên đây là INFO, không phải lỗi W6. Chỉ lưu ý: đừng trích câu chú thích đó ra ngoài như bằng chứng "HĐ nháp đã kín".
1.7 Deleted-view :425-431 — cùng vị ngữ CŨ, cố ý KHÔNG ghép (ĐÚNG acceptance, LỆCH anh em)
- Code:
q.Where(x => x.c.DrafterUserId == userId || eligiblePhases.Contains(x.c.Phase))— 0 thay đổi hành vi. - Khớp acceptance §③-B
:31(HĐChoDuyetxoá mềm KHÔNG rò sang user V2). Test T4:440-449đo, kèm chứng-nhân dương:453-455(người soạn VẪN thấy ⇒ 2 assert âm không xanh nhờ handler trả rỗng). ĐẠT. - ⚠️ Lệch anh em có chủ đích: KHKK
ContractSigningPlanFeatures.cs:770-785màn "Đã xoá" CÓ mirror y nguyên vị ngữ list-sống (kể cả vế workflow-membership) và tự chú thích "MIRROR list sống (cùng vị ngữ)". Contract W6 làm NGƯỢC. Cả hai đều "đúng theo tài liệu của mình" ⇒ 2 module cùng repo giờ có 2 luật khác nhau cho cùng câu hỏi. Không chặn commit (spec W6 thắng ở phạm vi này), nhưng nên ghi 1 dòng vàodocs/gotchas.mdhoặc §rules để lần sau không ai "đồng bộ hoá" nhầm chiều. → MINOR.
1.8 Kỹ thuật — 2 điểm cần nêu
- (a)
List<Guid?>+Containschỉ mới được chứng trên SQLite.:387-395trảList<Guid?>rồimyWorkflowIds.Contains(x.c.ApprovalWorkflowId); lập luận "NULL không khớp IN ⇒ HĐ V1 tự rớt" là đúng về logic SQL 3-trị, và toàn bộ test chạy trênSqliteDbFixture. Prod = SQL Server (EF Core 10 dịch collection-param quaOPENJSON). Chưa có phép đo nào chạm SQL Server. KHKK:275-283tránh hẳn chuyện này bằngList<Guid>+p.ApprovalWorkflowId != null && …Contains(p.ApprovalWorkflowId.Value). → MINOR-risk: acceptance rẻ = gọiGET /api/contractsbằng token 1 user có chân duyệt V2 trên Dev SQL Server (LocalDB) hoặc prod, thấy 200 + đúng dòng ⇒ đóng. NếuOPENJSONcãi kiểu thì lỗi là 500 lúc chạy, test SQLite không bắt được. - (b)
ResolveUserApprovalWorkflowIdsAsynckhông lọcApplicableType— user có chân trong workflow của PE/KHKK cũng vàomyWorkflowIds. Vô hại vì id đó không bao giờ khớpContract.ApprovalWorkflowId(HĐ chỉ pin workflowApplicableType=Contract, guard tạo HĐ chặn). Ghi lại để không ai bỏ guard tạo HĐ mà tưởng chỗ này vẫn kín. → INFO.
TRỤC 1 = PASS (0 critical). 1 MINOR-risk kỹ thuật (a) + 3 MINOR ghi-nhận (1.3 / 1.4 / 1.7) + 2 INFO.
TRỤC 2 — Admin-inbox nhánh phase tường minh
2.1 AdminInboxPhases = PhaseActorRoles.Keys + ChoDuyet — đối chiếu TỪNG PHẦN TỬ
| # | PhaseActorRoles keys (:470-479) |
AdminInboxPhases (:487-497) |
|---|---|---|
| 1 | DangSoanThao | DangSoanThao |
| 2 | DangGopY | DangGopY |
| 3 | DangDamPhan | DangDamPhan |
| 4 | DangInKy | DangInKy |
| 5 | DangKiemTraCCM | DangKiemTraCCM |
| 6 | DangTrinhKy | DangTrinhKy |
| 7 | DangDongDau | DangDongDau |
| 8 | — | ChoDuyet ← MỚI |
⇒ THUẦN CỘNG THÊM, 0 phase bị rơi. Chú thích :484 viết "7 phase legacy CŨ + ChoDuyet" —
con số ĐÚNG (đếm tay 7). ĐẠT.
2.2 KHÔNG lọt terminal
Enum ContractPhase có 12 giá trị. AdminInboxPhases không chứa
DaPhatHanh(9) · TuChoi(99) · TraLai(98) · DangChon(1). Test T6 :552-555 + :579-580
đo bằng NotIntersectWith trên đúng 3 phase kết thúc. ĐẠT.
2.3 v2InboxIds rỗng cho admin — ĐÚNG khuôn PE, không phải cắt xén
Đọc bờ PE (PurchaseEvaluationFeatures.cs:746-748):
var v2InboxIds = isAdmin ? new HashSet<Guid>() : await ResolveV2InboxIdsAsync(userId, ct);
W6 :516-518 y hệt. Admin đi bằng nhánh PHASE nên đủ. ĐẠT (mirror thật, đã đọc bờ kia,
không tin lời khai chú thích).
2.4 Điều-kiện-thoát-sớm — sửa ĐÚNG chỗ chí tử
:522 if (eligiblePhases.Count == 0 && v2InboxIds.Count == 0) return [];
Giữ nguyên bản cũ (Count == 0 một vế) thì approver 0 role nhận hộp thư rỗng dù có phiếu
⇒ một dòng return giết trọn tính năng. Test T1 cố ý cho approver A 0 role (:208) nên ca này
ĐƯỢC ĐO THẬT, không phải nói suông. ĐẠT.
2.5 Ba điểm CÒN HỞ / cần biết (không chặn commit)
- (a)
AdminInboxPhaseslà BẢN CHÉP TAY củaPhaseActorRoles.Keys⇒ mầm derived-drift. Thêm key mới vàoPhaseActorRolessau này mà quênAdminInboxPhases→ admin âm thầm mù phase đó. Test T6 KHÔNG bắt được chiều này: nó liệt kê literal 8 phase của riêng nó, nên phase thứ 9 mới thêm vào dict sẽ để T6 xanh nguyên. → MINOR. Acceptance rẻ nếu muốn đóng: 1 assertPhaseActorRoles.Keys ⊆ AdminInboxPhases(cần nớiprivate→internalcho dict). - (b)
TraLai(98)KHÔNG có trong hộp thư của BẤT KỲ AI (không trongPhaseActorRoles, không trongAdminInboxPhases;grep TraLai src/.../ContractFeatures.cs= 0 hit). HĐ bị Trả lại ⇒ người soạn chỉ tìm thấy qua màn Danh sách (vếDrafterUserId == userId), không có tín hiệu ở Inbox. VòngChoDuyet → TraLai → gửi lạivì thế chưa khép ở lớp hộp thư. PE mirror y hệt (GetEligiblePhases:691-709+ admin list:731-742đều không có TraLai) ⇒ đồng khuôn, không phải hồi quy W6, và spec W6 không xin. → MINOR / ngoài phạm vi. - (c) Chi phí
ResolveV2InboxIdsAsync: nạp TOÀN BỘ HĐChoDuyet(không lọc theo user) vào bộ nhớ mỗi lần gọi inbox của mỗi non-admin, rồi nạp thêm mọi workflow liên quan kèm Steps+Levels. Đúng khuôn PE/KHKK nên chấp nhận được, nhưng nó lớn theo số HĐ đang chờ, không theo số HĐ của người gọi. KèmTake(100)ở:534⇒ hộp thư đông có thể bị cắt ngọn. → INFO.
TRỤC 2 = PASS (0 critical). 2 MINOR + 1 INFO.
TRỤC 3 — Guard TRÌNH: 4 vế + null-trap + có mở transition nào khác không
3.1 Khai triển guard (ContractWorkflowService.cs:82-92)
Cho qua ⟺ isAdmin ∨ isSystem ∨ Drafter ∨ DeptManager ∨ Procurement ∨ isCreator,
với isCreator = actorUserId is Guid a ∧ (CreatedBy == a ∨ DrafterUserId == a).
3.2 Null-trap — ĐÓNG THẬT, nhưng khai đúng mức
actorUserId is Guid actorIdchặn được canull == null= TRUE. ĐẠT, test T7 ca 4 (:647-658) đo đúng ca đó với tiền-đề được assert tường minh (c4.CreatedBy.Should().BeNull():649) ⇒ phép đo không thể xanh ở "thế giới khác". Đây là chỗ làm tốt nhất của cả diff.- ⚠️ Nhưng ca đó KHÔNG với tới được từ API hiện tại:
TransitionContractCommandHandler:223-224némUnauthorizedExceptionkhicurrentUser.UserId is null, vàgrep TransitionAsynccho thấy duy nhất 1 caller (ContractFeatures.cs:229). Không có job/SLA nào gọi. ⇒ guard này là phòng thủ chiều sâu, không phải bịt lỗ đang chảy. Chú thích test:591-593("một lệnh trình KHÔNG DANH TÍNH đi lọt") đọc như mô tả lỗ SỐNG — nên hạ giọng thành "sẽ lọt nếu sau này có caller hệ thống". → INFO (độ chính xác lời khai), giữ nguyên mã.
3.3 isSystem bypass — không phải cửa mới, và hiện KHÔNG có ai đi
isSystem = actorUserId is null && decision == AutoApprove (:46). Từ API, actorUserId không bao
giờ null (3.2) ⇒ nhánh này hiện chết. FE gửi decision=AutoApprove cũng vô hại vì actor có id.
Đúng thiết kế đã ghi trong skill contract-workflow ("System bypass"). ĐẠT, → INFO.
3.4 isCreator KHÔNG giả mạo được — đã truy 2 write-path
DrafterUserIdgán từcurrentUser.UserIdtại create (ContractFeatures.cs:96) và không nằm trongUpdateContractDraftCommand(:136-145, 7 field, không có DrafterUserId) ⇒ không đổi được.CreatedBydoAuditingInterceptorgán. Không có endpoint nào set. ⇒ Không có đường "tự phong mình làm người tạo rồi trình HĐ người khác". ĐẠT.
3.5 Thêm Procurement mở ĐÚNG 1 cửa — đã đọc call-site, không suy đoán
grep -n "Procurement\|AppRoles\." ContractWorkflowService.cs → Procurement xuất hiện đúng 1 lần
trong mã chạy (:87), phần còn lại là chú thích. Soi 4 nhánh của TransitionAsync:
| Nhánh | Điều kiện vào | Procurement có lợi thêm không |
|---|---|---|
REJECT :49-66 |
decision == Reject |
KHÔNG (nhánh này 0 guard cho MỌI người — xem 3.6) |
TRÌNH :70-102 |
from ∈ {Nháp, TraLai} | CÓ — cửa duy nhất mở thêm |
APPROVE :105-201 → ApproveV2Async:230+ |
from = ChoDuyet | KHÔNG — match theo ApproverUserId (:271-278), không đọc role |
Admin override :204-212 |
isAdmin |
KHÔNG |
Thêm nữa, nhánh TRÌNH ép Phase = ChoDuyet (:93) bất kể targetPhase client gửi, nên
Procurement không nhảy thẳng được sang DaPhatHanh. ĐẠT — đúng "dòng tối thiểu" như spec §②-5.
3.6 🔴 Điều PHẢI nói dù spec bảo "đừng đụng": W6 khuếch đại lỗ O-A
Nhánh REJECT (:49-66) chạy TRƯỚC mọi guard và không có bất kỳ kiểm tra vai/quan hệ nào;
TransitionContractCommandHandler cũng chỉ kiểm IsAuthenticated; ContractsController:60-61
POST /{id}/transitions không có [Authorize(Policy=...)] (class chỉ [Authorize] trần —
đúng lớp gotcha #82). ⇒ bất kỳ user đăng nhập nào biết contractId đều Trả-lại/Từ-chối được HĐ
bất kỳ, kể cả HĐ mình không có quyền XEM.
Đây KHÔNG phải lỗi W6 (0-diff, anh chốt ĐỂ MỞ) — nhưng W6 vừa mở màn Danh sách/Chi tiết cho một
tập người dùng rộng hơn nhiều, tức làm việc lấy contractId trở nên dễ. Bán kính nổ của lỗ cũ
tăng theo. Không chặn commit; ghi ở đây để khi đóng O-A thì biết nó đã rộng ra từ lúc nào.
→ MAJOR (pre-existing, amplified — không yêu cầu sửa trong W6).
3.7 Hệ quả phụ của việc thêm Procurement (spec-sanctioned, cần nói rõ)
Procurement là vế theo ROLE, không kèm quan hệ với HĐ ⇒ một NV Mua hàng trình được MỌI HĐ
đang ở Nháp/TraLai, kể cả HĐ người khác soạn. Cùng lớp với Drafter/DeptManager vốn đã vậy
(pre-existing), và spec §②-5 xin đúng chữ đó. Rào tự nhiên còn lại: Procurement không thấy HĐ
Nháp của người khác (GetEligiblePhases:354-355 chỉ phát DangSoanThao cho Drafter/DeptManager;
vế V2 mới loại Nháp) ⇒ khó lấy id. Hành vi có log (LogTransitionAsync ghi actorUserId).
→ MINOR.
TRỤC 3 = PASS (0 critical trong phạm vi W6). 1 MAJOR pre-existing-amplified + 1 MINOR + 2 INFO.
TRỤC 4-7 + VERDICT — [LEAD đóng on-behalf @S161: vai chết #53 sau Trục-3]
TRỤC 4 — fidelity Resolve mirror: ĐẠT (hành vi khoá bởi 7 test pass — OR-of-N/con-trỏ đôi qua Inbox_Pending/OtherLevel; trục-1 đã xác nhận per-HĐ AnyAsync đúng workflowId).
TRỤC 5 — test-integrity: ĐẠT — 7 test = 5 PIN đúng NGUYÊN VĂN + 2 bonus đúng đặt hàng (Inbox_Admin_SeesChoDuyetContract DR-4 · Submit_ProcurementAndCreator_Allowed_UnrelatedRoleAndNullActor_Forbidden khoá cả 4-vế guard + null-trap trong 1 test). Suite 587/0 lead-tươi.
TRỤC 6-7 — boundary: ĐẠT — CWS diff CHỈ khối guard :73-78 (Reject :49-66 + ApproveV2Async :217-394 KHÔNG đụng) · KHKK module + Migrations 0-diff.
VERDICT: PASS_WITH_FLAGS — 0 chặn. 2 ghi-nhận chuyển owner (KHÔNG phải lỗi W6):
- Độ-rộng view = "workflow-blanket by design" (trục-1.3): vế V2 là per-HĐ-THEO-QUY-TRÌNH (có chân trong quy trình ⇒ thấy MỌI HĐ pin quy trình đó), không phải per-HĐ-được-giao — đúng chữ spec, nhưng owner cần biết độ rộng khi số HĐ tăng.
- Reject :49-66 pre-existing AMPLIFIED (trục-3.6): W6 mở list/detail rộng hơn ⇒ lấy
contractIddễ hơn ⇒ bán kính nổ của lỗ O-A-để-mở TĂNG. Ghi mốc để khi đóng O-A biết nó rộng ra từ đây.