Trường Hợp Sử Dụng

Giải quyết CAPTCHA để kiểm tra điểm cuối API trong biểu mẫu web

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.php lẫn res.php.
  • sitekey và pageurl thật của form: mở DevTools trên trang cần test, tìm data-sitekey trong 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.

Os comentários estão desativados para este artigo.