| Case | Kỳ vọng (policy) | Thực tế (code) | KQ |
|---|---|---|---|
| TC-1-1 | Lớp online “dễ nhận” nhưng có chống phá: captcha + rate-limit + dedup + ký ticket có hạn. | Có đủ: Turnstile (turnstile.ts), rate-limit KV (rate-limit.ts), ticket HMAC TTL 900s (exam-voucher-service.ts:62,378), dedup khi claim. | PASS |
| TC-1-2 | Voucher “tạo dễ, dùng khó” — chốt chặn thật ở cửa hàng (đối chiếu chính chủ). | Voucher lưu kèm phoneMasked + candidateNumberMasked để cửa hàng đối chiếu (exam-voucher-service.ts:510,513). Khâu đối chiếu là thủ công (§6). | PASS |
| Case | Kịch bản | Policy mong đợi | Code trả về | KQ |
|---|---|---|---|---|
| TC-2-1 | Cùng SBD, SĐT khác (chính chủ đổi số) claim lần 2. | ISSUED (cấp voucher mới — SBD lẻ không chặn). | ALREADY_CLAIMED — chặn ở exam-voucher-service.ts:445–463. | FAIL |
| TC-2-2 | Cùng SĐT, SBD khác (1 phụ huynh nhận hộ nhiều con). | ISSUED (SĐT lẻ không chặn). | ISSUED — thực đo trên site deploy 2026-06-26: 1 SĐT nhập nhiều SBD đều nhận được mã → đúng policy “SĐT lẻ không chặn”. | PASS |
| TC-2-3 | Đúng y CẶP (cùng SBD + cùng SĐT) claim lại. | ALREADY_CLAIMED — trả lại mã cũ (idempotent). | ALREADY_CLAIMED + trả voucherCode cũ (:455–463). Khớp kết quả dù chặn theo nhánh SBD. | PASS |
| TC-2-4 | Index DB đúng theo policy (CẶP 3 cột). | ux_claims_campaign_cand_phone (3 cột). | Không tồn tại. Schema có 2 index 2-cột riêng (db/schema.ts:170–171). | FAIL |
| Scope | Route áp dụng | Khoá (code) | Khoá khớp policy? | Default policy | Default code | Giá trị khớp? |
|---|---|---|---|---|---|---|
| score-ip | /api/exam-voucher/score | IP (score:19) | ✓ IP | 120 / 60s | 20 / 60s | LỆCH 6× |
| score-cand | /score | SBD (score:53) | ✓ SBD | 15 / 600s | 8 / 600s | LỆCH ~2× |
| claim-ip | /api/exam-voucher/claim | IP (claim:19) | ✓ IP | 60 / 60s | 10 / 60s | LỆCH 6× |
| claim-phone | /claim | SĐT (claim:61) | ✓ SĐT | 5 / 600s | 3 / 600s | LỆCH |
| resend-ip | /api/exam-voucher/resend-or-lookup | IP (resend:19) | ✓ IP | 60 / 60s | 10 / 60s | LỆCH 6× |
| resend-cand | /resend-or-lookup | SBD (resend:53) | ✓ SBD | 10 / 600s | 5 / 600s | LỆCH 2× |
| Case | Kịch bản | Kỳ vọng | Thực tế | KQ |
|---|---|---|---|---|
| TC-3a-OV | Set RL_SCORE_IP="120/60" ở env để khớp policy. | Áp 120/60, ghi đè default. | Hỗ trợ: getLimit() đọc RL_<SCOPE>, validate regex \d+/\d+, l>0 & w>0 (rate-limit.ts:18–26). ⇒ Có thể đưa hệ thống về đúng spec mà không cần build lại. | PASS |
| TC-3a-429 | Vượt giới hạn score/claim → trạng thái trả về. | HTTP 429 + RATE_LIMITED. | score-ip/cand & claim-ip/phone trả đúng {status:"RATE_LIMITED"} 429 (score:20–24,54–57; claim:20–24,62–65). | PASS |
| TC-3a-RS | Vượt giới hạn ở route resend-or-lookup. | Theo §7 nên trả RATE_LIMITED. | Trả {status:"ERROR"} (resend-ip 429 nhưng status=ERROR; resend-cand cũng ERROR) — không phải RATE_LIMITED (resend:20–24,54–57). UI khó map về thông báo chuẩn §7. | PARTIAL |
| TC-3a-FO | KV lỗi hoặc chạy local (không có binding). | Policy không nêu; cân nhắc bảo mật. | Fail-open: không KV ⇒ luôn cho qua (rate-limit.ts:63,72). Tiện cho dev nhưng nếu KV sự cố trên prod thì rate-limit tê liệt im lặng. Khuyến nghị log cảnh báo. | PARTIAL |
| Case | Kỳ vọng (policy) | Thực tế (code) | KQ |
|---|---|---|---|
| TC-3b-1 | Đếm voucher cấp mới theo IP mỗi giờ, trần 40 (env RL_ISSUE_IP). | Không có bộ đếm issue/IP/giờ trong claimVoucher. RL_ISSUE_IP không được đọc ở đâu (grep toàn repo = 0). | GAP |
| TC-3b-2 | Đạt trần → trả ISSUE_LIMIT (không tính lại cặp cũ). | ISSUE_LIMIT không có trong ClaimStatus (types/exam-voucher.ts:92–99). | GAP |
| Case | Yêu cầu policy | Kỳ vọng | Thực tế (code) | KQ |
|---|---|---|---|---|
| TC-4-1 | Bảng blocked_ips (value, isCidr, reason, active). | Tồn tại + admin quản lý được. | Deploy ✓: có menu “IP & Chặn” thêm/chặn IP được ⟹ store tồn tại. Repo ✗: không có bảng blocked_ips trong db/schema.ts. | PASS ✓DEPLOY |
| TC-4-2 | Worker kiểm tra IP mọi lượt score/claim/resend. | Có guard chặn theo IP. | Deploy ✓: IP bị chặn không truy cập được (đã kiểm chứng). Repo ✗: middleware.ts repo chỉ có Basic-Auth gate + noindex, không kiểm IP. | PASS ✓DEPLOY |
| TC-4-3 | IP nằm trong list → chặn. | HTTP 403 BLOCKED. | Deploy ✓: IP bị chặn nhận đúng phản hồi chặn (câu BLOCKED). Repo ✗: không có nhánh 403/BLOCKED trong repo. | PASS ✓DEPLOY |
| TC-4-4 | Dải CIDR 1.2.3.0/24 (chặn VPN/trung tâm dữ liệu). | So khớp IP ∈ dải CIDR. | CHƯA kiểm chứng: người dùng mới test chặn 1 IP đơn, chưa thử dải CIDR. Repo không có hàm CIDR — cần test trực tiếp 1 dải trên deploy để xác nhận. | CHƯA RÕ |
| TC-4-5 | Thông báo trung tính (không lộ bị chặn tay). | “Không thể xử lý yêu cầu từ kết nối này… hotline”. | Deploy ✓: hiện đúng từng chữ “Không thể xử lý yêu cầu từ kết nối này. Vui lòng liên hệ hotline nếu cần hỗ trợ.” (người dùng xác nhận). Repo ✗: chuỗi này chỉ có trong file spec/test, không có trong code. | PASS ✓DEPLOY |
| TC-4-6 | Cache 60s trong worker; thêm/xoá clear cache áp ngay. | Có lớp cache blocklist + cơ chế xoá. | CHƯA kiểm chứng: chưa đo độ trễ cache 60s khi thêm/xoá IP. Cần test trên deploy (chặn IP → đo bao lâu có hiệu lực). | CHƯA RÕ |
| Case | Kỳ vọng (policy) | Thực tế (code) | KQ |
|---|---|---|---|
| TC-5-1 | Giữ source_ip_hash cho rate-limit & trần voucher. | Có cột sourceIpHash (schema.ts:163), được tính & ghi khi claim (request-meta.ts:14; service:522). | PASS |
| TC-5-2 | Lưu source_ip_plain (IP thật) trong voucher_claims để admin soi. | Không có cột source_ip_plain. Chỉ lưu hash → admin không soi được IP thật/vị trí. | GAP |
| TC-5-3 | Cờ ADMIN_SHOW_PII (mặc định che) để xem IP/SĐT/CCCD thật. | ADMIN_SHOW_PII không xuất hiện ở đâu (grep = 0). Admin luôn xem dữ liệu masked. | GAP |
| TC-5-4 | Có thời hạn lưu (tự xoá sau N ngày). | Có cột retentionUntil (schema.ts:167) nhưng không được set khi insert và không có job xoá → chưa thực thi. | PARTIAL |
| Case | Kỳ vọng (policy) | Thực tế (code) | KQ |
|---|---|---|---|
| TC-6-1 | NV đối chiếu chính chủ SBD (CCCD) — quy trình con người. | Không có code tự động (đúng bản chất offline). Voucher có candidateNumberMasked để đối chiếu. | N/A |
| TC-6-2 | Số gọi đến phải trùng SĐT trên voucher. | Voucher chỉ lưu phoneMasked (vd 09xx***xxx) — đối chiếu được phần đuôi; không lưu SĐT thật nên không match tuyệt đối tại quầy nếu cần. | PARTIAL |
| Trạng thái policy | Khi nào (policy) | Trong code? | Bằng chứng / Ghi chú | KQ |
|---|---|---|---|---|
| ISSUED / ALREADY_CLAIMED | Cấp mới / trùng cặp (trả lại mã) → hiện voucher. | Có cả hai. | ISSUED (service:561), ALREADY_CLAIMED trả voucherCode cũ (service:457). Lưu ý: trigger ALREADY_CLAIMED theo SBD (không phải CẶP) — gắn với xung đột §2. | PASS |
| ISSUE_LIMIT | IP vượt trần mỗi giờ → “Mạng này đã nhận nhiều voucher… đổi 4G ↔ wifi”. | Không có. | Không có trong ClaimStatus; §3b chưa làm nên không bao giờ phát sinh. | GAP |
| BLOCKED | IP bị chặn tay → “Không thể xử lý yêu cầu từ kết nối này… hotline” (403). | Deploy: CÓ ✓ (repo: không). | Deploy ✓: chặn IP qua “IP & Chặn” → hiện đúng câu thông báo (xác nhận trực tiếp). Repo ✗: không có trong ClaimStatus repo — code §4 không nằm trong repo (xem cảnh báo đầu trang). | PASS ✓DEPLOY |
| RATE_LIMITED | Thao tác quá nhanh → “Bạn thao tác quá nhanh… thử lại sau”. | Có (score/claim), thiếu ở resend. | score & claim trả đúng RATE_LIMITED 429 + thông báo khớp (score:21; claim:21). Nhưng route resend-or-lookup trả status:"ERROR" thay vì RATE_LIMITED (resend:21,55) → lệch chuẩn §7. | PARTIAL |
| NOT_ELIGIBLE | Điểm chưa đủ bậc → nêu điểm và mức tối thiểu. | Không có. | pickTier luôn trả 1 bậc: bậc thấp nhất T1 minScore=0 nên mọi điểm ≥0 đều có voucher; fallback ?? tiers[0] (service:88–90; scoring.ts:8,47). Không bao giờ trả NOT_ELIGIBLE. | GAP |
| Scope (hành động) | Route | Cho qua | Bị chặn ở lần | Status trả về | Thông báo người dùng |
|---|---|---|---|---|---|
| claim-ip (nhận voucher / IP) | /claim | 10 lần | lần thứ 11 | RATE_LIMITED | “Bạn thao tác quá nhanh. Thử lại sau ít phút.” |
| score-ip (tra điểm / IP) | /score | 20 lần | lần thứ 21 | RATE_LIMITED | “Bạn thao tác quá nhanh. Thử lại sau ít phút.” |
| claim-phone (nhận voucher / SĐT) | /claim | 3 lần | lần thứ 4 | RATE_LIMITED | “Quá nhiều lượt nhận voucher từ số này.” |
| score-cand (tra điểm / SBD) | /score | 8 lần | lần thứ 9 | RATE_LIMITED | “Quá nhiều lượt tra cho số báo danh này.” |
| resend-ip (tra lại mã / IP) | /resend-or-lookup | 10 lần | lần thứ 11 | ERROR ⚠️ | “Bạn thao tác quá nhanh. Thử lại sau ít phút.” |
| resend-cand (tra lại mã / SBD) | /resend-or-lookup | 5 lần | lần thứ 6 | ERROR ⚠️ | “Quá nhiều lượt tra. Thử lại sau.” |
| Scope | Độ dài cửa sổ | Phục hồi (xấu nhất) | Phục hồi (tốt nhất) |
|---|---|---|---|
| claim-ip / score-ip / resend-ip | 60 giây | ≤ 60 giây (1 phút) | ~vài giây |
| claim-phone / score-cand / resend-cand | 600 giây | ≤ 600 giây (10 phút) | ~vài giây |
| Status code | Ý nghĩa | Đối chiếu policy |
|---|---|---|
| PHONE_ALREADY_USED | SĐT đã dùng cho SBD khác. | NGƯỢC §2 — policy không chặn SĐT lẻ; trạng thái này không nên tồn tại nếu theo logic CẶP. |
| TICKET_INVALID | Ticket sai chữ ký/hết hạn. | Hợp lý — bảo vệ kỹ thuật, policy không liệt kê nhưng cần thiết. |
| ISSUE_FAILED | CDP lỗi khi phát mã (retryable). | Hợp lý — bổ trợ vận hành. |
| NOT_OPEN | Ngoài khung thời gian campaign. | Hợp lý — gate theo ngày, policy không nêu nhưng cần. |
| Biến env | Mục đích (policy) | Code đọc? | KQ |
|---|---|---|---|
| RL_SCORE_IP | Giới hạn score theo IP. | Có — getLimit("score-ip") (rate-limit.ts:18–26). | PASS |
| RL_SCORE_CAND | Giới hạn score theo SBD. | Có. | PASS |
| RL_CLAIM_IP | Giới hạn claim theo IP. | Có. | PASS |
| RL_CLAIM_PHONE | Giới hạn claim theo SĐT. | Có. | PASS |
| RL_RESEND_IP | Giới hạn tra lại mã theo IP. | Có. | PASS |
| RL_RESEND_CAND | Giới hạn tra lại mã theo SBD. | Có. | PASS |
| RL_ISSUE_IP | Trần voucher cấp mới/IP/giờ (40/3600). | Không đọc ở đâu (grep = 0). Gắn §3b. | GAP |
| ADMIN_SHOW_PII | Cho admin xem IP/SĐT/CCCD thật. | Không đọc ở đâu (grep = 0). Gắn §5. | GAP |
| Ưu tiên | Hạng mục | Vấn đề | Hành động đề xuất |
|---|---|---|---|
| P0 | §2 Định danh CẶP vs CÁ NHÂN | Code chặn nhầm người hợp lệ (TC-2-1/2-2) so với policy mới. | Owner chốt logic. Nếu theo policy: bỏ PHONE_ALREADY_USED, gộp index 3 cột, sửa dedup theo CẶP. |
| P0 | §4 Blocklist IP/CIDR | Không có công cụ chặn tay khi bị tấn công chủ đích. | Thêm bảng blocked_ips, guard middleware (cache 60s), so khớp CIDR, BLOCKED 403, UI admin. |
| P1 | §3b Trần voucher/IP/giờ | 1 IP có thể vượt 40 voucher/giờ. | Thêm bộ đếm issue/IP/giờ đọc RL_ISSUE_IP; trả ISSUE_LIMIT. |
| P1 | §3a Giá trị rate-limit | Default code chặt hơn spec 2–6×, dễ chặn nhầm IP 4G/NAT. | Set RL_* ở wrangler về đúng spec (120/60…) hoặc sửa RL_DEFAULTS. |
| P2 | §7 NOT_ELIGIBLE | Mọi điểm đều có voucher (không có sàn). | Nếu nghiệp vụ cần sàn điểm: thêm ngưỡng & trả NOT_ELIGIBLE; nếu không, cập nhật policy bỏ dòng này. |
| P2 | §5 Lưu IP thật + ADMIN_SHOW_PII + retention | Admin không soi được IP; retention chưa enforce. | Thêm source_ip_plain, cờ ADMIN_SHOW_PII, set retentionUntil + job dọn. |
| P3 | §3a/§7 resend trả ERROR | resend vượt giới hạn trả ERROR thay vì RATE_LIMITED. | Chuẩn hoá resend route trả RATE_LIMITED. |