Files
robotex/tests/unit/test_v2.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

213 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""/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
window = env.redis.hgetall(session_key(sid))
assert (window["worker_id"], window["idle_timeout"], window["client_id"]) == (WORKER_ID, "5", "1")
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
def test_old_profiles_cleaned_up(env):
"""#20: профили старше PROFILE_TTL_DAYS удаляются, кроме тех, где открыто окно."""
import os
import time
from worker.windows import LAST_USED_FILE
create(env, url="https://example.com", browser_idle_timeout=5)
held = env.client.cookies["session_id"]
elsewhere, old, fresh = (str(uuid.uuid4()) for _ in range(3))
env.redis.hset(session_key(elsewhere), mapping={"worker_id": "w2", "idle_timeout": 5})
long_ago = time.time() - 8 * 86400
for sid in (held, elsewhere, old, fresh, "not-a-session"):
(env.profiles / sid).mkdir(exist_ok=True)
marker = env.profiles / sid / LAST_USED_FILE
marker.touch()
if sid != fresh:
os.utime(marker, (long_ago, long_ago))
assert env.call(env.pool.cleanup_profiles) == [old]
assert sorted(p.name for p in env.profiles.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"])
def test_profile_marked_as_used(env):
from worker.windows import LAST_USED_FILE
create(env, url="https://example.com")
assert (env.profiles / env.client.cookies["session_id"] / LAST_USED_FILE).exists()