- 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>
This commit is contained in:
1 parent
9dc2c0b619
commit
1a4bd8d672
34 files changed
+857
-81
No files matched your search
@@ -22,7 +22,9 @@ def worker(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
|
||||
from db.engine import connect as connect_db
|
||||
from worker import actors
|
||||
monkeypatch.setattr(actors, "connect_db", lambda: connect_db(f"sqlite+aiosqlite:///{tmp_path / 'robotex.db'}"))
|
||||
w = dramatiq.Worker(actors.broker, worker_timeout=100, worker_threads=2)
|
||||
w.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""API-ключи (#4) и журнал окон и запросов (#17)."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import cli, clients
|
||||
from db.engine import connect as connect_db
|
||||
from db.models import Client, Request, Window
|
||||
|
||||
from .conftest import FakeSession
|
||||
|
||||
URL = "https://example.com"
|
||||
|
||||
|
||||
def rows(env, model):
|
||||
async def query():
|
||||
async with env.db.session() as session:
|
||||
return list(await session.scalars(select(model).order_by(model.id)))
|
||||
return env.call(query)
|
||||
|
||||
|
||||
def new_key(env, name="other"):
|
||||
return env.call(clients.create_client, env.db, name)[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_", [
|
||||
("post", "/v1/solve", {"url": URL, "actions": []}),
|
||||
("post", "/v2/tasks", {"url": URL, "actions": []}),
|
||||
("get", f"/v2/tasks/{uuid.uuid4()}", None),
|
||||
("delete", "/v2/sessions/current", None),
|
||||
])
|
||||
@pytest.mark.parametrize("key", [None, "", "rbx_wrong"])
|
||||
def test_requests_without_valid_key_rejected(env, request_, key):
|
||||
method, path, body = request_
|
||||
headers = {"X-API-Key": key} if key is not None else {}
|
||||
env.client.headers.pop("X-API-Key")
|
||||
resp = env.client.request(method, path, json=body, headers=headers)
|
||||
assert resp.status_code == 401
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
def test_health_is_open(env):
|
||||
env.client.headers.pop("X-API-Key")
|
||||
assert env.client.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_revoked_key_rejected(env):
|
||||
assert env.client.post("/v1/solve", json={"url": URL, "actions": []}).status_code == 200
|
||||
assert env.call(clients.revoke, env.db, 1)
|
||||
assert env.client.post("/v1/solve", json={"url": URL, "actions": []}).status_code == 401
|
||||
|
||||
|
||||
def test_no_clients_means_no_access(env):
|
||||
env.call(clients.revoke, env.db, 1)
|
||||
assert [c.revoked_at is not None for c in rows(env, Client)] == [True]
|
||||
assert env.client.post("/v2/tasks", json={"url": URL, "actions": []}).status_code == 401
|
||||
|
||||
|
||||
def test_last_access_tracked(env):
|
||||
assert rows(env, Client)[0].last_access_at is None
|
||||
env.client.post("/v2/tasks", json={"url": URL, "actions": []})
|
||||
assert rows(env, Client)[0].last_access_at is not None
|
||||
|
||||
|
||||
def test_task_and_window_visible_only_to_their_client(env):
|
||||
task_id = env.client.post("/v2/tasks", json={"url": URL, "actions": [], "browser_idle_timeout": 5}).json()["task_id"]
|
||||
other = {"X-API-Key": new_key(env)}
|
||||
|
||||
# та же cookie сессии, но другой ключ: задача и окно — как чужие
|
||||
assert env.client.get(f"/v2/tasks/{task_id}", headers=other).status_code == 404
|
||||
assert env.client.post("/v2/tasks", json={"actions": []}, headers=other).status_code == 422
|
||||
assert env.client.delete("/v2/sessions/current", headers=other).json() == {"closed": False}
|
||||
assert env.pool.used == 1
|
||||
|
||||
assert env.client.get(f"/v2/tasks/{task_id}").json()["status"] == "done"
|
||||
assert env.client.delete("/v2/sessions/current").json() == {"closed": True}
|
||||
|
||||
|
||||
def test_journal_request_and_window(env):
|
||||
env.client.post("/v2/tasks", json={
|
||||
"url": URL, "proxy": "http://user:TopSecret1@host:3128", "captcha": "KEYSECRET2",
|
||||
"actions": [{"type": "typing", "selector": "#p", "value": "PassSecret3"}],
|
||||
})
|
||||
env.client.post("/v1/solve", json={"url": URL, "actions": []})
|
||||
|
||||
requests = rows(env, Request)
|
||||
assert [(r.api, r.status, r.client_id) for r in requests] == [("v2", "ok", 1), ("v1", "ok", 1)]
|
||||
for r in requests:
|
||||
assert r.created_at <= r.started_at <= r.finished_at
|
||||
assert r.duration_ms is not None and r.window_id is not None
|
||||
data = str(requests[0].task_data)
|
||||
assert requests[0].task_data["url"] == URL
|
||||
for secret in ("user", "TopSecret1", "KEYSECRET2", "PassSecret3"):
|
||||
assert secret not in data
|
||||
|
||||
windows = rows(env, Window)
|
||||
assert len(windows) == 2
|
||||
assert all(w.closed_at is not None and w.started_at <= w.closed_at for w in windows)
|
||||
assert [w.task_id for w in windows] == [r.task_id for r in requests]
|
||||
|
||||
|
||||
def test_journal_held_window(env):
|
||||
for body in ({"url": URL, "browser_idle_timeout": 5}, {"browser_idle_timeout": 5}):
|
||||
env.client.post("/v2/tasks", json={"actions": [], **body})
|
||||
window, = rows(env, Window)
|
||||
assert window.closed_at is None and window.last_access_at > window.started_at
|
||||
assert {r.window_id for r in rows(env, Request)} == {window.id}
|
||||
|
||||
env.client.delete("/v2/sessions/current")
|
||||
assert rows(env, Window)[0].closed_at is not None
|
||||
|
||||
|
||||
def test_journal_failed_request(env):
|
||||
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
|
||||
env.client.post("/v2/tasks", json={"url": URL, "actions": []})
|
||||
request, = rows(env, Request)
|
||||
assert (request.status, request.code) == ("err", "request_failed")
|
||||
|
||||
|
||||
def test_journal_failure_does_not_break_task(env, monkeypatch):
|
||||
class DownDatabase:
|
||||
def session(self):
|
||||
raise RuntimeError("db is down")
|
||||
monkeypatch.setattr(env.pool.journal, "db", DownDatabase())
|
||||
task_id = env.client.post("/v2/tasks", json={"url": URL, "actions": []}).json()["task_id"]
|
||||
assert env.client.get(f"/v2/tasks/{task_id}").json()["result"]["status"] == "ok"
|
||||
|
||||
|
||||
def test_cli_create_list_revoke(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{tmp_path / 'cli.db'}")
|
||||
asyncio.run(cli.run(argparse.Namespace(command="create", name="acme")))
|
||||
key = capsys.readouterr().out.strip().splitlines()[-1]
|
||||
assert key.startswith(clients.KEY_PREFIX)
|
||||
|
||||
asyncio.run(cli.run(argparse.Namespace(command="list")))
|
||||
listing = capsys.readouterr().out
|
||||
assert "acme" in listing and "active" in listing and key not in listing
|
||||
|
||||
asyncio.run(cli.run(argparse.Namespace(command="revoke", id=1)))
|
||||
assert capsys.readouterr().out.strip() == "revoked"
|
||||
|
||||
asyncio.run(cli.run(argparse.Namespace(command="stats", days=7)))
|
||||
assert "запросов не было" in capsys.readouterr().out
|
||||
|
||||
async def check():
|
||||
db = connect_db()
|
||||
try:
|
||||
return await clients.authenticate(db, key)
|
||||
finally:
|
||||
await db.close()
|
||||
assert asyncio.run(check()) is None
|
||||
|
||||
|
||||
def test_usage_stats(env, monkeypatch):
|
||||
from api import actions as api_actions
|
||||
from db import stats
|
||||
|
||||
from .conftest import fake_execute
|
||||
|
||||
env.client.post("/v2/tasks", json={"url": URL, "actions": [], "browser_idle_timeout": 5})
|
||||
env.client.post("/v2/tasks", json={"actions": []}) # в открытом окне, после неё окно закрывается
|
||||
monkeypatch.setattr(api_actions, "execute", fake_execute(exc=api_actions.ChallengeFailed("cf")))
|
||||
env.client.post("/v1/solve", json={"url": URL, "actions": []})
|
||||
|
||||
other = {"X-API-Key": new_key(env, "acme")}
|
||||
env.client.cookies.clear()
|
||||
monkeypatch.setattr(api_actions, "execute", fake_execute())
|
||||
env.client.post("/v1/solve", json={"url": URL, "actions": []}, headers=other)
|
||||
|
||||
usage = {u.name: u for u in env.call(stats.usage, env.db, stats.since_days(1))}
|
||||
test, acme = usage["test"], usage["acme"]
|
||||
assert (test.requests, test.ok, test.errors, test.windows) == (3, 2, {"challenge_failed": 1}, 2)
|
||||
assert (acme.requests, acme.ok, acme.windows) == (1, 1, 1)
|
||||
assert test.avg_ms is not None and test.p95_ms is not None
|
||||
assert test.browser_seconds > 0
|
||||
# все окна закрыты: время браузера больше не растёт
|
||||
again = {u.name: u.browser_seconds for u in env.call(stats.usage, env.db, stats.since_days(1))}
|
||||
assert again == {name: u.browser_seconds for name, u in usage.items()}
|
||||
# период фильтрует запросы и окна
|
||||
assert env.call(stats.usage, env.db, stats.since_days(-1)) == []
|
||||
@@ -67,8 +67,8 @@ async def test_finish_sets_result_and_ttl(store):
|
||||
|
||||
|
||||
async def test_live_window_requires_live_worker(store):
|
||||
await store.set_session("s", "w1", 5, 300)
|
||||
await store.set_session("s", "w1", 5, 300, client_id=7)
|
||||
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.live_window("s") == {"worker_id": "w1", "idle_timeout": "5", "client_id": "7"}
|
||||
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
|
||||
@@ -109,21 +109,21 @@ def test_invalid_request_is_422_without_task(env):
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
def test_session_cookie_reused(env, tmp_path):
|
||||
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(tmp_path / 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, tmp_path, bad):
|
||||
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 tmp_path.iterdir()] == [sid]
|
||||
assert [p.name for p in env.profiles.iterdir()] == [sid]
|
||||
|
||||
|
||||
def test_user_agent_param_rejected(env):
|
||||
|
||||
@@ -90,7 +90,8 @@ def test_window_held_and_reused(env):
|
||||
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"}
|
||||
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 — на текущей странице
|
||||
@@ -180,7 +181,7 @@ def test_dead_worker_window_goes_to_common_queue(env):
|
||||
assert env.messages[-1].queue_name == COMMON_QUEUE
|
||||
|
||||
|
||||
def test_old_profiles_cleaned_up(env, tmp_path):
|
||||
def test_old_profiles_cleaned_up(env):
|
||||
"""#20: профили старше PROFILE_TTL_DAYS удаляются, кроме тех, где открыто окно."""
|
||||
import os
|
||||
import time
|
||||
@@ -194,18 +195,18 @@ def test_old_profiles_cleaned_up(env, tmp_path):
|
||||
|
||||
long_ago = time.time() - 8 * 86400
|
||||
for sid in (held, elsewhere, old, fresh, "not-a-session"):
|
||||
(tmp_path / sid).mkdir(exist_ok=True)
|
||||
marker = tmp_path / sid / LAST_USED_FILE
|
||||
(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 tmp_path.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"])
|
||||
assert sorted(p.name for p in env.profiles.iterdir()) == sorted([held, elsewhere, fresh, "not-a-session"])
|
||||
|
||||
|
||||
def test_profile_marked_as_used(env, tmp_path):
|
||||
def test_profile_marked_as_used(env):
|
||||
from worker.windows import LAST_USED_FILE
|
||||
|
||||
create(env, url="https://example.com")
|
||||
assert (tmp_path / env.client.cookies["session_id"] / LAST_USED_FILE).exists()
|
||||
assert (env.profiles / env.client.cookies["session_id"] / LAST_USED_FILE).exists()
|
||||
Reference in new issue
Block a user