Test suite CI/CD treo ở bước submit form vì gặp reCAPTCHA hay Cloudflare Turnstile? Đừng cố mở trình duyệt thật để "diễn" lại thao tác người dùng — phần lớn bài test endpoint chỉ cần một token hợp lệ. Bài này chỉ cách giải CAPTCHA qua API, gắn token vào payload, rồi POST thẳng tới endpoint backend: không Selenium, không headless browser.
Luồng xử lý: giải token rồi POST thẳng vào endpoint
┌──────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────────┐
│ Solve │────▶│ Build │────▶│ POST to │────▶│ Validate │
│ CAPTCHA │ │ Request │ │ Endpoint │ │ Response │
│ (API) │ │ Payload │ │ │ │ │
└──────────┘ └────────────┘ └──────────────┘ └──────────────┘
Bốn bước thay hoàn toàn cho việc mở trình duyệt: giải CAPTCHA → gắn token vào payload → POST tới endpoint → kiểm tra response.
Khi nào cần kiểm tra endpoint qua API thay vì trình duyệt
- Kiểm tra xác thực backend: server có thật sự kiểm tra token, hay chấp nhận cả giá trị sai?
- Test tải (load test): bắn hàng loạt request tới endpoint có CAPTCHA, không cần mở cả trăm tab trình duyệt.
- Test tích hợp CI/CD: chạy test submit form ngay trong pipeline, trên máy không GUI.
- Test response lỗi: đúng mã lỗi khi token sai hoặc hết hạn?
Tình huống quen thuộc với team QA outsourcing ở TP.HCM hay Hà Nội: form checkout dùng Cloudflare Turnstile, pipeline CI cần chạy hàng trăm lần submit mỗi ngày trước khi release. Mở trình duyệt thật cho từng lần chạy vừa chậm vừa tốn CI runner — giải token qua API rồi POST thẳng vào endpoint staging nhanh hơn nhiều.
Chuẩn bị trước khi test
Trước khi chạy suite test, cần chuẩn bị bốn thứ sau — thiếu một trong số đó, phần lớn thời gian debug sẽ tốn vào việc đoán thay vì đọc log:
- API key CaptchaAI: lấy trong dashboard, dùng cho cả
in.phplẫnres.php. - sitekey và pageurl thật của form: mở DevTools trên trang cần test, tìm
data-sitekeytrong HTML hoặc trong script khởi tạo reCAPTCHA/Turnstile. - Endpoint staging, không phải production: test tải và test token sai nên chạy trên môi trường staging để không làm nhiễu dữ liệu thật hay dính rate limit của production.
- Quyền xem log backend: để đối chiếu response test với log server, biết chính xác vì sao một request bị từ chối thay vì đoán mò.
Triển khai
Class lấy token CAPTCHA
Gọi in.php để tạo task, polling res.php tới khi có token.
import time
import requests
class TokenProvider:
BASE = "https://ocr.captchaai.com"
def __init__(self, api_key):
self.api_key = api_key
def get_recaptcha_token(self, sitekey, pageurl, version="v2"):
params = {
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
}
if version == "v3":
params["version"] = "v3"
params["action"] = "submit"
return self._solve(params, initial_wait=15 if version == "v3" else 10)
def get_turnstile_token(self, sitekey, pageurl):
return self._solve({
"method": "turnstile",
"sitekey": sitekey,
"pageurl": pageurl,
})
def _solve(self, params, initial_wait=10):
params["key"] = self.api_key
params["json"] = 1
resp = requests.post(f"{self.BASE}/in.php", data=params).json()
if resp["status"] != 1:
raise Exception(resp["request"])
task_id = resp["request"]
time.sleep(initial_wait)
for _ in range(60):
result = requests.get(
f"{self.BASE}/res.php",
params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
).json()
if result["request"] == "CAPCHA_NOT_READY":
time.sleep(5)
continue
if result["status"] == 1:
return result["request"]
raise Exception(result["request"])
raise TimeoutError("Timed out")
reCAPTCHA v3 chờ 15 giây trước lần polling đầu vì thường mất nhiều thời gian hơn v2; Turnstile dùng mặc định 10 giây.
Class kiểm tra endpoint
Lấy token từ TokenProvider, gắn vào payload, rồi chạy ba kịch bản trên cùng một endpoint: submit hợp lệ, token sai, thiếu token.
import json
import time
class EndpointTester:
def __init__(self, api_key):
self.token_provider = TokenProvider(api_key)
self.session = requests.Session()
self.results = []
def test_endpoint(self, config):
"""
config: {
"name": "test name",
"url": "endpoint URL",
"method": "POST",
"captcha_type": "recaptcha_v2" | "recaptcha_v3" | "turnstile",
"sitekey": "...",
"pageurl": "...",
"captcha_field": "g-recaptcha-response",
"payload": { ... form data ... },
"expected_status": 200,
"expected_contains": "success",
}
"""
start = time.time()
result = {"name": config["name"], "passed": False}
try:
# Get CAPTCHA token
captcha_type = config.get("captcha_type", "recaptcha_v2")
if captcha_type == "recaptcha_v2":
token = self.token_provider.get_recaptcha_token(
config["sitekey"], config["pageurl"]
)
elif captcha_type == "recaptcha_v3":
token = self.token_provider.get_recaptcha_token(
config["sitekey"], config["pageurl"], version="v3"
)
elif captcha_type == "turnstile":
token = self.token_provider.get_turnstile_token(
config["sitekey"], config["pageurl"]
)
else:
raise ValueError(f"Unknown captcha type: {captcha_type}")
# Build payload
payload = {**config.get("payload", {})}
captcha_field = config.get("captcha_field", "g-recaptcha-response")
payload[captcha_field] = token
# Submit request
method = config.get("method", "POST").upper()
headers = config.get("headers", {})
if config.get("json_body"):
resp = self.session.request(
method, config["url"], json=payload, headers=headers
)
else:
resp = self.session.request(
method, config["url"], data=payload, headers=headers
)
# Validate response
result["status_code"] = resp.status_code
result["response_length"] = len(resp.text)
result["elapsed"] = round(time.time() - start, 2)
# Check expected status
expected_status = config.get("expected_status", 200)
if resp.status_code != expected_status:
result["error"] = f"Expected {expected_status}, got {resp.status_code}"
self.results.append(result)
return result
# Check expected content
expected = config.get("expected_contains")
if expected and expected.lower() not in resp.text.lower():
result["error"] = f"Response missing: '{expected}'"
self.results.append(result)
return result
result["passed"] = True
except Exception as e:
result["error"] = str(e)
result["elapsed"] = round(time.time() - start, 2)
self.results.append(result)
return result
def test_invalid_token(self, config):
"""Test that endpoint rejects invalid CAPTCHA tokens."""
invalid_config = {**config}
invalid_config["name"] = f"{config['name']} (invalid token)"
# Override with fake token
payload = {**config.get("payload", {})}
captcha_field = config.get("captcha_field", "g-recaptcha-response")
payload[captcha_field] = "INVALID_TOKEN_12345"
start = time.time()
result = {"name": invalid_config["name"], "passed": False}
try:
resp = self.session.post(config["url"], data=payload)
result["status_code"] = resp.status_code
result["elapsed"] = round(time.time() - start, 2)
# Should reject — 4xx or error message
if resp.status_code >= 400 or "error" in resp.text.lower() or "invalid" in resp.text.lower():
result["passed"] = True
else:
result["error"] = "Endpoint accepted invalid CAPTCHA token"
except Exception as e:
result["error"] = str(e)
result["elapsed"] = round(time.time() - start, 2)
self.results.append(result)
return result
def test_missing_token(self, config):
"""Test that endpoint rejects missing CAPTCHA token."""
start = time.time()
result = {"name": f"{config['name']} (missing token)", "passed": False}
try:
payload = config.get("payload", {})
resp = self.session.post(config["url"], data=payload)
result["status_code"] = resp.status_code
result["elapsed"] = round(time.time() - start, 2)
if resp.status_code >= 400 or "captcha" in resp.text.lower():
result["passed"] = True
else:
result["error"] = "Endpoint accepted request without CAPTCHA"
except Exception as e:
result["error"] = str(e)
result["elapsed"] = round(time.time() - start, 2)
self.results.append(result)
return result
def run_suite(self, configs):
"""Run a full test suite against multiple endpoints."""
for config in configs:
self.test_endpoint(config)
self.test_invalid_token(config)
self.test_missing_token(config)
return self.report()
def report(self):
passed = sum(1 for r in self.results if r["passed"])
total = len(self.results)
lines = [f"Endpoint Tests: {passed}/{total} passed", "=" * 50]
for r in self.results:
status = "PASS" if r["passed"] else "FAIL"
elapsed = r.get("elapsed", "?")
lines.append(f" [{status}] {r['name']} ({elapsed}s)")
if r.get("error"):
lines.append(f" Error: {r['error']}")
return "\n".join(lines)
run_suite() chạy toàn bộ config qua cả ba kịch bản, trả về báo cáo pass/fail.
Ví dụ chạy thử
Định nghĩa endpoint cần test trong một list config rồi chạy suite:
tester = EndpointTester("YOUR_API_KEY")
configs = [
{
"name": "Contact form submission",
"url": "https://example.com/api/contact",
"captcha_type": "recaptcha_v2",
"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
"pageurl": "https://example.com/contact",
"captcha_field": "g-recaptcha-response",
"payload": {
"name": "Test User",
"email": "[email protected]",
"message": "Automated test message",
},
"expected_status": 200,
"expected_contains": "success",
},
{
"name": "Newsletter signup",
"url": "https://example.com/api/subscribe",
"captcha_type": "turnstile",
"sitekey": "0x4AAAA...",
"pageurl": "https://example.com/newsletter",
"captcha_field": "cf-turnstile-response",
"payload": {
"email": "[email protected]",
},
"expected_status": 200,
},
]
report = tester.run_suite(configs)
print(report)
Đầu ra:
Endpoint Tests: 5/6 passed
==================================================
[PASS] Contact form submission (18.5s)
[PASS] Contact form submission (invalid token) (0.3s)
[PASS] Contact form submission (missing token) (0.2s)
[PASS] Newsletter signup (14.2s)
[FAIL] Newsletter signup (invalid token) (0.3s)
Error: Endpoint accepted invalid CAPTCHA token
[PASS] Newsletter signup (missing token) (0.2s)
Kết quả cho thấy Newsletter signup chấp nhận cả token giả — backend chưa kiểm tra CAPTCHA, cần báo ngay cho team backend.
Các lỗi thường gặp khi test
| Vấn đề | Nguyên nhân | Cách xử lý |
|---|---|---|
| Token hợp lệ bị từ chối | Token đã hết hạn trước khi gửi | Rút ngắn thời gian giữa lúc giải và lúc submit |
| Token giả vẫn được chấp nhận | Backend không xác thực CAPTCHA | Báo lỗi bảo mật ngay — đây là lỗ hổng thật, không phải lỗi test |
| Mọi request đều trả 403 | Thiếu cookie phiên hoặc CSRF token | Thêm cookie phiên hoặc header CSRF vào request |
| Endpoint JSON từ chối form data | Sai content-type | Đặt json_body: True trong config |
Lưu ý: nếu không chắc token bị từ chối do hết hạn hay do backend chưa validate, ghi lại timestamp lúc giải xong token và lúc submit request — chênh lệch quá lớn giữa hai mốc thời gian thường là nguyên nhân chính.
Câu hỏi thường gặp
CaptchaAI hỗ trợ những loại CAPTCHA nào để test endpoint?
Phổ biến nhất là reCAPTCHA v2, reCAPTCHA v3 và Cloudflare Turnstile — cả ba dùng được với TokenProvider ở trên. CaptchaAI còn giải Cloudflare Challenge, GeeTest v3, CAPTCHA ảnh/OCR, grid image và BLS. hCaptcha, FunCaptcha chưa hỗ trợ; GeeTest v4 sắp ra mắt.
Test endpoint có CAPTCHA tốn bao nhiêu mỗi tháng?
CaptchaAI tính phí theo thread chạy đồng thời, không theo từng lần giải. BASIC ($15/tháng, 5 thread) đủ cho phần lớn pipeline test hàng ngày; nhiều job song song thì cân nhắc STANDARD ($30/tháng, 15 thread) hoặc ADVANCE ($90/tháng, 50 thread).
Có thể test endpoint mà không cần giải CAPTCHA thật không?
Có, với test token sai hoặc thiếu token — chỉ cần gửi giá trị rỗng hoặc chuỗi giả. Để xác minh submit thành công thì cần token thật, vì backend sẽ verify ngược lại với Google hoặc Cloudflare.
Vì sao POST token hợp lệ vẫn nhận lỗi 403?
Thường không phải do token sai, mà do thiếu cookie phiên hoặc header CSRF. Bắt request thật từ DevTools rồi so sánh từng header với request test của bạn.
Nên mock CAPTCHA hay dùng token thật trong CI/CD?
Mock ở unit test để pipeline chạy nhanh, không phụ thuộc dịch vụ ngoài. Dùng token thật ở bước integration/end-to-end, ngay trước khi merge, để chắc luồng xác thực thật sự hoạt động chứ không chỉ mock.
Hướng dẫn liên quan
Test mọi endpoint có CAPTCHA — dùng CaptchaAI.