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

184 lines
7.2 KiB
Python

"""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)) == []