- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests. - Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без выпущенных ключей доступа нет. В базе — только sha256 ключа. - Задача и окно сессии привязаны к клиенту: чужой ключ их не видит. - Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу. - python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats. - smoke и loadtest с ключом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
"""/v1/solve поверх очереди: контракт v1 не изменился (#84)."""
|
|
import logging
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
import main
|
|
from api import actions
|
|
from worker import windows
|
|
from worker.windows import Outcome
|
|
|
|
from .conftest import FakeSession, fake_execute
|
|
|
|
|
|
def post(env, **body):
|
|
return env.client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
|
|
|
|
|
|
def test_ok(env, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
|
|
resp = post(env, 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(env):
|
|
data = post(env).json()
|
|
assert "html" not in data and "screenshot" not in data
|
|
assert data["actions"] == []
|
|
|
|
|
|
def test_action_failed(env, 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(env, 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(env, monkeypatch):
|
|
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
|
|
data = post(env, 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(env):
|
|
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
|
|
data = post(env).json()
|
|
assert data == {
|
|
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
|
|
"actions": [], "duration_ms": data["duration_ms"],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("workers", ["busy", "none"])
|
|
def test_no_slots(env, workers):
|
|
if workers == "busy":
|
|
env.heartbeat(free_slots=0)
|
|
else:
|
|
env.redis.flushall()
|
|
resp = post(env)
|
|
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 env.messages == []
|
|
|
|
|
|
def test_not_picked_up_in_time_is_503_and_cancelled(env, monkeypatch):
|
|
env.auto = False
|
|
monkeypatch.setattr(main, "V1_QUEUE_WAIT", 0)
|
|
resp = post(env)
|
|
assert resp.status_code == 503
|
|
assert resp.json()["code"] == "no_slots"
|
|
|
|
# воркер позже достаёт сообщение — отменённую задачу не выполняет
|
|
env.deliver_pending()
|
|
assert env.outcomes == [Outcome.SKIPPED]
|
|
assert FakeSession.instances == []
|
|
|
|
|
|
def test_slot_released_after_request(env):
|
|
FakeSession.error = RuntimeError("boom")
|
|
for _ in range(3):
|
|
assert post(env).status_code == 200
|
|
assert env.pool.used == 0 and env.pool.windows == {}
|
|
assert all(s.closed for s in FakeSession.instances)
|
|
|
|
|
|
def test_invalid_request_is_422_without_task(env):
|
|
resp = post(env, actions=[{"type": "nope"}])
|
|
assert resp.status_code == 422
|
|
assert env.messages == []
|
|
|
|
|
|
def test_session_cookie_reused(env):
|
|
sid = str(uuid.uuid4())
|
|
env.client.cookies.set("session_id", sid)
|
|
resp = post(env)
|
|
assert resp.cookies["session_id"] == sid
|
|
assert FakeSession.instances[0].kwargs["user_data_dir"] == str(env.profiles / sid)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
|
|
def test_bad_session_cookie_gets_new_session(env, bad):
|
|
env.client.cookies.set("session_id", bad)
|
|
resp = post(env)
|
|
sid = resp.cookies["session_id"]
|
|
assert sid != bad and uuid.UUID(sid)
|
|
assert [p.name for p in env.profiles.iterdir()] == [sid]
|
|
|
|
|
|
def test_user_agent_param_rejected(env):
|
|
resp = post(env, user_agent="First/1")
|
|
assert resp.status_code == 422
|
|
assert "take user_agent from the response" in resp.text
|
|
assert env.messages == []
|
|
|
|
|
|
def test_fingerprint_passed_to_browser_and_ua_untouched(env):
|
|
post(env)
|
|
kwargs = FakeSession.instances[0].kwargs
|
|
assert "useragent" not in kwargs
|
|
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
|
|
|
|
|
|
def test_cookies_replace_profile_cookies_only_when_passed(env):
|
|
post(env)
|
|
post(env, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
|
post(env, cookies=[])
|
|
assert [s.kwargs["clear_cookies"] for s in FakeSession.instances] == [False, True, True]
|
|
|
|
|
|
def test_proxy_passed_and_geo_resolved(env, monkeypatch):
|
|
seen = {}
|
|
|
|
async def fake_geo(proxy):
|
|
seen["proxy"] = proxy
|
|
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
|
|
|
|
monkeypatch.setattr(windows, "resolve_geo", fake_geo)
|
|
post(env, proxy="http://u:p@host:3128")
|
|
kwargs = FakeSession.instances[0].kwargs
|
|
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(env, monkeypatch):
|
|
async def fail_geo(proxy):
|
|
raise AssertionError("geo must not be resolved without proxy")
|
|
|
|
monkeypatch.setattr(windows, "resolve_geo", fail_geo)
|
|
post(env)
|
|
assert FakeSession.instances[0].kwargs["proxy"] is None
|
|
|
|
|
|
def test_secrets_not_logged_nor_stored(env, monkeypatch, caplog):
|
|
async def no_geo(proxy):
|
|
return None
|
|
|
|
monkeypatch.setattr(windows, "resolve_geo", no_geo)
|
|
caplog.set_level(logging.DEBUG)
|
|
post(
|
|
env, 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"},
|
|
{"type": "typing", "selector": "#p", "value": "PassSecret5"},
|
|
],
|
|
)
|
|
log = caplog.text
|
|
assert "Incoming payload" in log and "Outgoing payload" in log
|
|
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "PassSecret5", "secret-cookie", "<body>page"):
|
|
assert secret not in log
|
|
|
|
# в Redis — только результат (там cookies и html страницы), но не секреты запроса
|
|
stored = " ".join(str(env.redis.hgetall(k)) for k in env.redis.keys("*task*"))
|
|
for secret in ("TopSecret1", "KEYSECRET2", "PassSecret4"):
|
|
assert secret not in stored
|
|
|
|
|
|
def test_health(env):
|
|
data = env.client.get("/health").json()
|
|
assert data["status"] == "ok"
|
|
assert (data["browser_slots"], data["free_slots"]) == (2, 2)
|
|
assert [w["worker_id"] for w in data["workers"]] == ["w1"]
|