- user_agent в запросе → 422 с подсказкой брать UA из ответа: UA, отличный от реального Chrome, расходится с userAgentData и движком, Cloudflare такой браузер не пропускает (замер: родной 3/3, Chrome 147 и Windows — 0) (#87) - профиль хранит только отпечаток (экран, DPR), UA не подменяется (#89) - docs/Usage.md: практики для клиентов API — тот же UA и прокси, что у браузера, сессии, cookies, ошибки и повторы, таймауты, пример на httpx - README, docs/API.md, docs/Introduction.md; тесты под новый контракт Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.8 KiB
Python
241 lines
7.8 KiB
Python
"""/v1/solve без браузера: движок подменён, проверяются сборка ответа, коды ошибок, слоты, сессии и логи."""
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import main
|
|
from api import actions, profile
|
|
|
|
|
|
class FakeContext:
|
|
async def cookies(self):
|
|
return [{"name": "cf_clearance", "value": "secret-cookie"}]
|
|
|
|
|
|
class FakePage:
|
|
context = FakeContext()
|
|
|
|
async def screenshot(self, type):
|
|
return b"png"
|
|
|
|
async def inner_html(self, selector):
|
|
return "<body>page</body>"
|
|
|
|
async def evaluate(self, js):
|
|
return "UA/engine"
|
|
|
|
|
|
class FakeSession:
|
|
"""Вместо AsyncStealthySession: запоминает kwargs и вызывает page_action на FakePage."""
|
|
calls: list[dict] = []
|
|
error: Exception | None = None
|
|
|
|
def __init__(self, **kwargs):
|
|
FakeSession.calls.append(kwargs)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def fetch(self, url, page_action):
|
|
if FakeSession.error:
|
|
raise FakeSession.error
|
|
return await page_action(FakePage())
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, tmp_path):
|
|
FakeSession.calls, FakeSession.error = [], None
|
|
monkeypatch.setattr(main, "AsyncStealthySession", FakeSession)
|
|
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
|
|
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(2))
|
|
return TestClient(main.app)
|
|
|
|
|
|
def fake_execute(result=None, exc=None):
|
|
async def execute(*args, **kwargs):
|
|
if exc:
|
|
raise exc
|
|
return result
|
|
return execute
|
|
|
|
|
|
def post(client, **body):
|
|
return client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
|
|
|
|
|
|
def test_ok(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
|
|
resp = post(client, html=True, screenshot=True)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "ok"
|
|
assert "code" not in data and "error" not in data
|
|
assert data["user_agent"] == "UA/engine"
|
|
assert data["actions"] == [{"type": "wait_for", "ok": True, "duration_ms": 5}]
|
|
assert data["html"] == "<body>page</body>"
|
|
assert data["screenshot"] == "cG5n" # base64(b"png")
|
|
assert data["duration_ms"] >= 0
|
|
assert uuid.UUID(resp.cookies["session_id"])
|
|
|
|
|
|
def test_html_and_screenshot_only_on_request(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
data = post(client).json()
|
|
assert "html" not in data and "screenshot" not in data
|
|
assert data["actions"] == []
|
|
|
|
|
|
def test_action_failed(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([
|
|
{"type": "click", "ok": True, "duration_ms": 1},
|
|
{"type": "inner_html", "ok": False, "result": "selector not found: #x", "duration_ms": 1},
|
|
]))
|
|
data = post(client, screenshot=True).json()
|
|
assert data["status"] == "err"
|
|
assert data["code"] == "action_failed"
|
|
assert data["error"] == "inner_html: selector not found: #x"
|
|
assert data["screenshot"]
|
|
|
|
|
|
def test_challenge_failed(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
|
|
data = post(client, screenshot=True).json()
|
|
assert data["status"] == "err"
|
|
assert data["code"] == "challenge_failed"
|
|
assert data["error"] == "cloudflare not passed: timeout"
|
|
assert data["actions"] == []
|
|
assert data["cookies"] and data["screenshot"]
|
|
|
|
|
|
def test_request_failed(client):
|
|
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
|
|
data = post(client).json()
|
|
assert data == {
|
|
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
|
|
"actions": [], "duration_ms": data["duration_ms"],
|
|
}
|
|
|
|
|
|
def test_no_slots(client, monkeypatch):
|
|
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(0))
|
|
resp = post(client)
|
|
assert resp.status_code == 503
|
|
assert resp.headers["retry-after"] == main.RETRY_AFTER
|
|
assert resp.json() == {"status": "err", "code": "no_slots", "error": "No slots available"}
|
|
assert FakeSession.calls == []
|
|
|
|
|
|
def test_slot_released_after_request(client, monkeypatch):
|
|
sem = asyncio.Semaphore(1)
|
|
monkeypatch.setattr(main, "_slots", sem)
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
FakeSession.error = RuntimeError("boom")
|
|
for _ in range(3):
|
|
assert post(client).status_code == 200
|
|
assert not sem.locked()
|
|
|
|
|
|
def test_invalid_request_is_422_without_browser(client):
|
|
resp = post(client, actions=[{"type": "nope"}])
|
|
assert resp.status_code == 422
|
|
assert FakeSession.calls == []
|
|
|
|
|
|
def test_session_cookie_reused(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
sid = str(uuid.uuid4())
|
|
client.cookies.set("session_id", sid)
|
|
resp = post(client)
|
|
assert resp.cookies["session_id"] == sid
|
|
assert FakeSession.calls[0]["user_data_dir"] == str(tmp_path / sid)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
|
|
def test_bad_session_cookie_gets_new_session(client, monkeypatch, tmp_path, bad):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
client.cookies.set("session_id", bad)
|
|
resp = post(client)
|
|
sid = resp.cookies["session_id"]
|
|
assert sid != bad and uuid.UUID(sid)
|
|
assert [p.name for p in tmp_path.iterdir()] == [sid]
|
|
|
|
|
|
def test_user_agent_param_rejected(client):
|
|
resp = post(client, user_agent="First/1")
|
|
assert resp.status_code == 422
|
|
assert "take user_agent from the response" in resp.text
|
|
assert FakeSession.calls == []
|
|
|
|
|
|
def test_fingerprint_passed_to_browser_and_ua_untouched(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
post(client)
|
|
kwargs = FakeSession.calls[0]
|
|
assert "useragent" not in kwargs
|
|
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
|
|
|
|
|
|
def test_cookies_replace_profile_cookies_only_when_passed(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
post(client)
|
|
post(client, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
|
post(client, cookies=[])
|
|
assert [c["clear_cookies"] for c in FakeSession.calls] == [False, True, True]
|
|
|
|
|
|
def test_proxy_passed_and_geo_resolved(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
seen = {}
|
|
|
|
async def fake_geo(proxy):
|
|
seen["proxy"] = proxy
|
|
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
|
|
|
|
monkeypatch.setattr(main, "resolve_geo", fake_geo)
|
|
post(client, proxy="http://u:p@host:3128")
|
|
kwargs = FakeSession.calls[0]
|
|
assert kwargs["proxy"] == {"server": "http://host:3128", "username": "u", "password": "p"}
|
|
assert seen["proxy"] == kwargs["proxy"]
|
|
assert (kwargs["locale"], kwargs["timezone_id"]) == ("de-DE", "Europe/Berlin")
|
|
|
|
|
|
def test_without_proxy_no_geo(client, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
|
|
async def fail_geo(proxy):
|
|
raise AssertionError("geo must not be resolved without proxy")
|
|
|
|
monkeypatch.setattr(main, "resolve_geo", fail_geo)
|
|
post(client)
|
|
assert FakeSession.calls[0]["proxy"] is None
|
|
|
|
|
|
def test_secrets_not_logged(client, monkeypatch, caplog):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
|
|
|
async def no_geo(proxy):
|
|
return None
|
|
|
|
monkeypatch.setattr(main, "resolve_geo", no_geo)
|
|
caplog.set_level(logging.DEBUG, logger="main")
|
|
post(
|
|
client, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True,
|
|
cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}],
|
|
actions=[{"type": "fill", "selector": "#p", "value": "PassSecret4"}],
|
|
)
|
|
log = caplog.text
|
|
assert "Incoming payload" in log and "Outgoing payload" in log
|
|
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "secret-cookie", "<body>page"):
|
|
assert secret not in log
|
|
|
|
|
|
def test_health(client):
|
|
assert client.get("/health").json() == {"status": "ok", "browser_slots": main.BROWSER_SLOTS, "free_slots": 2}
|