Files
robotex/tests/unit/test_v1.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)
- 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>
2026-10-13 21:48:48 +04:00

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"]