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