"""/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 "page" 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)) monkeypatch.setattr(profile, "chrome_major", lambda: "155") 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"] == "page" 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_set_once_per_profile(client, monkeypatch): monkeypatch.setattr(actions, "execute", fake_execute([])) post(client, user_agent="First/1") post(client, user_agent="Second/2") assert [c["useragent"] for c in FakeSession.calls] == ["First/1", "First/1"] def test_generated_fingerprint_passed_to_browser(client, monkeypatch): monkeypatch.setattr(actions, "execute", fake_execute([])) post(client) kwargs = FakeSession.calls[0] assert kwargs["useragent"] == profile.UA_TEMPLATE.format(major="155") 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", "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}