- 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>
184 lines
7.2 KiB
Python
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)) == []
|