feature: архитектура v2 — api + очередь + worker, удержание окна браузера (#78)
- Сервис api (FastAPI, без Chrome): POST /v2/tasks, GET /v2/tasks/{id} (доступ по cookie
сессии), DELETE /v2/sessions/current; /v1/solve — прежний контракт поверх очереди.
- Сервис worker (dramatiq + RabbitMQ): слоты браузера, окна сессий между задачами
(browser_idle_timeout 0–120 мин, простой — TTL ключа в Redis), heartbeat.
- Маршрутизация задач сессии в персональную очередь воркера с открытым окном.
- Redis: задачи, результаты (RESULT_TTL_HOURS), окна, воркеры. Прокси и ключ 2captcha
в Redis не сохраняются.
- Движок: AsyncStealthySession.run() — действия на текущей странице без навигации.
- Два образа (robotex-api, robotex-worker), compose с rabbitmq и redis,
make up/down/logs/smoke, релиз собирает все образы.
- Тесты: api + worker в одном процессе (fakeredis), настоящий воркер dramatiq на
StubBroker, браузерные тесты на новой схеме, smoke для запущенного стенда.
- Документация: README, API (v2), Deployment, Usage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe509c25c3
commit
7b511d5d38
38 files changed
+2142
-545
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,80 @@
|
||||
"""Сервисы api и worker без браузера: Chrome — FakeSession (окружение — tests/harness.py)."""
|
||||
import pytest
|
||||
|
||||
from api import actions
|
||||
from tests.harness import WORKER_ID, run_env # noqa: F401
|
||||
from worker import windows
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.ops = []
|
||||
|
||||
async def cookies(self):
|
||||
return [{"name": "cf_clearance", "value": "secret-cookie"}]
|
||||
|
||||
async def clear_cookies(self):
|
||||
self.ops.append("clear")
|
||||
|
||||
async def add_cookies(self, cookies):
|
||||
self.ops.append(("add", cookies))
|
||||
|
||||
|
||||
class FakePage:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
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."""
|
||||
instances: list["FakeSession"] = []
|
||||
error: Exception | None = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.context = FakeContext()
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
FakeSession.instances.append(self)
|
||||
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
async def fetch(self, url, page_action, **kwargs):
|
||||
self.calls.append(("fetch", url))
|
||||
if FakeSession.error:
|
||||
raise FakeSession.error
|
||||
return await page_action(FakePage(self.context))
|
||||
|
||||
async def run(self, page_action, **kwargs):
|
||||
self.calls.append(("run", None))
|
||||
return await page_action(FakePage(self.context))
|
||||
|
||||
|
||||
def fake_execute(result=None, exc=None):
|
||||
async def execute(*args, **kwargs):
|
||||
if exc:
|
||||
raise exc
|
||||
return result if result is not None else []
|
||||
return execute
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch, tmp_path):
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
with run_env(monkeypatch, tmp_path) as harness:
|
||||
yield harness
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Настоящий воркер dramatiq на брокере в памяти: actor'ы, event loop AsyncIO, пул окон и heartbeat."""
|
||||
import asyncio
|
||||
|
||||
import dramatiq
|
||||
import fakeredis
|
||||
import pytest
|
||||
|
||||
from api import actions
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import COMMON_QUEUE, personal_queue, task_message
|
||||
from worker import windows
|
||||
|
||||
from .conftest import FakeSession, fake_execute
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(monkeypatch, tmp_path):
|
||||
server = fakeredis.FakeServer()
|
||||
monkeypatch.setattr(task_store, "connect", lambda url=None: fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(windows, "USER_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
|
||||
from worker import actors
|
||||
w = dramatiq.Worker(actors.broker, worker_timeout=100, worker_threads=2)
|
||||
w.start()
|
||||
try:
|
||||
yield actors, server
|
||||
finally:
|
||||
w.stop()
|
||||
# при остановке воркер закрывает окна и снимает heartbeat
|
||||
assert all(s.closed for s in FakeSession.instances)
|
||||
assert not fakeredis.FakeRedis(server=server).keys("robotex:worker:*")
|
||||
|
||||
|
||||
SID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def submit(actors, server, queue, idle=0):
|
||||
"""То же, что делает api: задача в Redis, сообщение в очередь; ждём, пока воркер её выполнит."""
|
||||
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
asyncio.run(store.create("t1", SID, "https://example.com"))
|
||||
actors.broker.enqueue(task_message("t1", SID, {"url": "https://example.com", "actions": []}, idle, queue))
|
||||
actors.broker.join(queue)
|
||||
return fakeredis.FakeRedis(server=server, decode_responses=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue", ["common", "personal"])
|
||||
def test_task_executed_from_queue(worker, queue):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue(actors.WORKER_ID))
|
||||
task = redis.hgetall("robotex:task:t1")
|
||||
assert task["status"] == "done", task
|
||||
assert '"status": "ok"' in task["result"]
|
||||
assert redis.exists(f"robotex:worker:{actors.WORKER_ID}")
|
||||
|
||||
|
||||
def test_window_held_until_shutdown(worker):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE, idle=5)
|
||||
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == actors.WORKER_ID
|
||||
assert not FakeSession.instances[0].closed
|
||||
@@ -1,240 +0,0 @@
|
||||
"""/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}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fakeredis
|
||||
import pytest
|
||||
|
||||
from tasks import store as task_store
|
||||
from tasks.store import Status, TaskStore
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return TaskStore(fakeredis.aioredis.FakeRedis(decode_responses=True))
|
||||
|
||||
|
||||
async def test_claim_once(store):
|
||||
await store.create("t", "s", "https://example.com")
|
||||
await store.heartbeat("w1", {})
|
||||
assert (await store.get("t"))["status"] == Status.QUEUED
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w2")
|
||||
assert not await store.cancel("t")
|
||||
|
||||
|
||||
async def test_cancelled_task_is_not_claimed(store):
|
||||
await store.create("t", "s", None)
|
||||
assert await store.cancel("t")
|
||||
assert not await store.claim("t", "w1")
|
||||
assert (await store.get("t"))["status"] == Status.CANCELLED
|
||||
|
||||
|
||||
async def test_missing_task_is_not_claimed(store):
|
||||
assert not await store.claim("nope", "w1")
|
||||
|
||||
|
||||
async def test_redelivery_while_running_here_is_skipped(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w1", {})
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w1", running_here=True)
|
||||
|
||||
|
||||
async def test_task_of_dead_worker_is_reclaimed_limited_times(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w2", {})
|
||||
assert await store.claim("t", "w1") # w1 умер — heartbeat нет
|
||||
assert await store.claim("t", "w2") # attempts = 2
|
||||
await store.remove_worker("w2")
|
||||
assert not await store.claim("t", "w3")
|
||||
task = await store.get("t")
|
||||
assert task["status"] == Status.FAILED and "worker died" in task["error"]
|
||||
|
||||
|
||||
async def test_task_of_live_worker_is_not_reclaimed(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w1", {})
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w2")
|
||||
|
||||
|
||||
async def test_finish_sets_result_and_ttl(store):
|
||||
await store.create("t", "s", None)
|
||||
assert await store.redis.ttl("robotex:task:t") == task_store.PENDING_TTL
|
||||
await store.finish("t", {"status": "ok", "actions": []})
|
||||
task = await store.get("t")
|
||||
assert task["status"] == Status.DONE and task["result"] == {"status": "ok", "actions": []}
|
||||
assert await store.redis.ttl("robotex:task:t") == task_store.RESULT_TTL
|
||||
|
||||
|
||||
async def test_live_window_requires_live_worker(store):
|
||||
await store.set_session("s", "w1", 5, 300)
|
||||
assert await store.live_window("s") is None
|
||||
await store.heartbeat("w1", {"browser_slots": 1, "free_slots": 0})
|
||||
assert await store.live_window("s") == {"worker_id": "w1", "idle_timeout": "5"}
|
||||
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""/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, tmp_path):
|
||||
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(tmp_path / sid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
|
||||
def test_bad_session_cookie_gets_new_session(env, tmp_path, 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 tmp_path.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"}],
|
||||
)
|
||||
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
|
||||
|
||||
# в 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"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""/v2: задачи через очередь, доступ по cookie сессии, удержание окна и маршрутизация в него (#80–#83)."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import COMMON_QUEUE, personal_queue
|
||||
from worker.windows import Outcome
|
||||
|
||||
from .conftest import FakeSession, WORKER_ID
|
||||
|
||||
|
||||
def create(env, **body):
|
||||
return env.client.post("/v2/tasks", json={"actions": [], **body})
|
||||
|
||||
|
||||
def get(env, task_id):
|
||||
return env.client.get(f"/v2/tasks/{task_id}")
|
||||
|
||||
|
||||
def session_key(sid):
|
||||
return f"{task_store.PREFIX}:session:{sid}"
|
||||
|
||||
|
||||
def test_create_and_get_result(env):
|
||||
resp = create(env, url="https://example.com", html=True)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["status"] == "queued" and body["url"] == f"/v2/tasks/{body['task_id']}"
|
||||
assert env.messages[0].queue_name == COMMON_QUEUE
|
||||
|
||||
task = get(env, body["task_id"]).json()
|
||||
assert task["status"] == "done"
|
||||
assert task["result"]["status"] == "ok"
|
||||
assert task["result"]["html"] == "<body>page</body>"
|
||||
assert task["result"]["user_agent"] == "UA/engine"
|
||||
|
||||
|
||||
def test_queued_until_worker_takes_it(env):
|
||||
env.auto = False
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
assert get(env, task_id).json() == {"task_id": task_id, "status": "queued"}
|
||||
env.deliver_pending()
|
||||
assert get(env, task_id).json()["status"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cookie", [None, "other", "garbage"])
|
||||
def test_task_visible_only_to_its_session(env, cookie):
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
env.client.cookies.clear()
|
||||
if cookie:
|
||||
env.client.cookies.set("session_id", str(uuid.uuid4()) if cookie == "other" else cookie)
|
||||
assert get(env, task_id).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_id", [str(uuid.uuid4()), "../etc", "x"])
|
||||
def test_unknown_task_404(env, task_id):
|
||||
create(env, url="https://example.com")
|
||||
assert get(env, task_id).status_code == 404
|
||||
|
||||
|
||||
def test_result_ttl(env):
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
ttl = env.redis.ttl(f"{task_store.PREFIX}:task:{task_id}")
|
||||
assert task_store.RESULT_TTL - 5 < ttl <= task_store.RESULT_TTL
|
||||
|
||||
|
||||
def test_url_required_without_window(env):
|
||||
resp = create(env)
|
||||
assert resp.status_code == 422
|
||||
assert "no open browser window" in resp.text
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 121])
|
||||
def test_idle_timeout_range(env, value):
|
||||
assert create(env, url="https://example.com", browser_idle_timeout=value).status_code == 422
|
||||
|
||||
|
||||
def test_window_closed_by_default(env):
|
||||
create(env, url="https://example.com")
|
||||
sid = env.client.cookies["session_id"]
|
||||
assert env.pool.windows == {} and env.pool.used == 0
|
||||
assert FakeSession.instances[0].closed
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_window_held_and_reused(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
engine = FakeSession.instances[0]
|
||||
assert not engine.closed and env.pool.used == 1
|
||||
assert env.redis.hgetall(session_key(sid)) == {"worker_id": WORKER_ID, "idle_timeout": "5"}
|
||||
assert 290 < env.redis.ttl(session_key(sid)) <= 300
|
||||
|
||||
# следующая задача сессии — в персональную очередь воркера с окном, без url — на текущей странице
|
||||
task_id = create(env, browser_idle_timeout=2).json()["task_id"]
|
||||
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
|
||||
assert get(env, task_id).json()["result"]["status"] == "ok"
|
||||
assert FakeSession.instances == [engine]
|
||||
assert engine.calls == [("fetch", "https://example.com"), ("run", None)]
|
||||
# простой продлён значением из последней задачи
|
||||
assert 110 < env.redis.ttl(session_key(sid)) <= 120
|
||||
|
||||
# задача с browser_idle_timeout=0 закрывает окно после себя
|
||||
create(env, url="https://example.com/next")
|
||||
assert engine.closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_cookies_replace_in_open_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
create(env, browser_idle_timeout=5, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
||||
create(env, browser_idle_timeout=5)
|
||||
assert FakeSession.instances[0].context.ops == [
|
||||
"clear", ("add", [{"name": "a", "value": "1", "url": "https://example.com"}]),
|
||||
]
|
||||
|
||||
|
||||
def test_proxy_change_restarts_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
create(env, url="https://example.com", browser_idle_timeout=5, proxy="http://host:3128")
|
||||
first, second = FakeSession.instances
|
||||
assert first.closed and not second.closed
|
||||
assert second.kwargs["proxy"] == {"server": "http://host:3128"}
|
||||
assert env.pool.used == 1
|
||||
|
||||
|
||||
def test_failure_in_window_closes_it(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
FakeSession.error = RuntimeError("net::ERR_TIMED_OUT")
|
||||
task_id = create(env, url="https://example.com", browser_idle_timeout=5).json()["task_id"]
|
||||
assert get(env, task_id).json()["result"]["code"] == "request_failed"
|
||||
assert FakeSession.instances[0].closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_idle_window_expires(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
env.call(env.pool.expire_idle)
|
||||
assert env.pool.used == 1
|
||||
|
||||
env.redis.delete(session_key(sid)) # TTL истёк
|
||||
env.call(env.pool.expire_idle)
|
||||
assert env.pool.used == 0 and FakeSession.instances[0].closed
|
||||
|
||||
|
||||
def test_delete_session_closes_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
assert env.client.delete("/v2/sessions/current").json() == {"closed": True}
|
||||
assert env.messages[-1].actor_name == "close_window"
|
||||
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
|
||||
assert FakeSession.instances[0].closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
assert env.client.delete("/v2/sessions/current").json() == {"closed": False}
|
||||
|
||||
|
||||
def test_open_windows_take_slots(env):
|
||||
for _ in range(2):
|
||||
env.client.cookies.clear()
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
assert env.pool.used == 2
|
||||
|
||||
env.client.cookies.clear()
|
||||
create(env, url="https://example.com")
|
||||
assert env.outcomes[-1] == Outcome.NO_SLOT
|
||||
# /v1/solve без окна при занятых слотах — сразу 503, как раньше
|
||||
env.client.cookies.clear()
|
||||
assert env.client.post("/v1/solve", json={"url": "https://example.com", "actions": []}).status_code == 503
|
||||
|
||||
|
||||
def test_dead_worker_window_goes_to_common_queue(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
env.redis.delete(f"{task_store.PREFIX}:worker:{WORKER_ID}")
|
||||
env.auto = False
|
||||
create(env, url="https://example.com")
|
||||
assert env.messages[-1].queue_name == COMMON_QUEUE
|
||||
Reference in new issue
Block a user