mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""Smoke tests for the Agent Gateway (`/api/agent/v1`).
|
|
|
|
These tests exercise the route layer in isolation:
|
|
* `/health` is reachable without a token.
|
|
* Token-required routes reject missing / malformed bearer tokens.
|
|
* Scope enforcement returns 403 (not 401) when the token is valid but
|
|
lacks the required capability class.
|
|
|
|
We monkey-patch `agent_auth._lookup_token` so we don't need a live Postgres
|
|
to validate the auth state machine.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.utils import agent_auth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_rate_limit_state():
|
|
agent_auth._rate_state.clear()
|
|
yield
|
|
agent_auth._rate_state.clear()
|
|
|
|
|
|
def _fake_token_row(scopes: str = "R", paper_only: bool = True, status: str = "active",
|
|
expires_at=None, rate_limit_per_min: int = 60) -> dict:
|
|
return {
|
|
"id": 999,
|
|
"user_id": 1,
|
|
"name": "test-agent",
|
|
"scopes": scopes,
|
|
"markets": "*",
|
|
"instruments": "*",
|
|
"paper_only": paper_only,
|
|
"rate_limit_per_min": rate_limit_per_min,
|
|
"status": status,
|
|
"expires_at": expires_at,
|
|
}
|
|
|
|
|
|
def _bearer(headers: dict | None = None, token: str = "qd_agent_TESTTOKEN12345") -> dict:
|
|
out = {"Authorization": f"Bearer {token}"}
|
|
out.update(headers or {})
|
|
return out
|
|
|
|
|
|
def test_health_is_public(client):
|
|
"""Public liveness probe should not require authentication."""
|
|
resp = client.get("/api/agent/v1/health")
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
assert body["service"] == "quantdinger-agent-gateway"
|
|
assert body["version"] == "v1"
|
|
assert body["status"] == "ok"
|
|
|
|
|
|
def test_whoami_requires_token(client):
|
|
"""Without a token the gateway must return 401, not 500."""
|
|
# Bypass schema bootstrap (no DB in unit tests) so we exit early.
|
|
agent_auth._schema_ready = True
|
|
resp = client.get("/api/agent/v1/whoami")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_unknown_token_rejected(client, monkeypatch):
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(agent_auth, "_lookup_token", lambda raw: None)
|
|
resp = client.get("/api/agent/v1/whoami", headers=_bearer())
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_inactive_token_rejected(client, monkeypatch):
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(
|
|
agent_auth, "_lookup_token",
|
|
lambda raw: _fake_token_row(status="revoked"),
|
|
)
|
|
resp = client.get("/api/agent/v1/whoami", headers=_bearer())
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_expired_token_rejected(client, monkeypatch):
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(
|
|
agent_auth, "_lookup_token",
|
|
lambda raw: _fake_token_row(expires_at=datetime.now() - timedelta(days=1)),
|
|
)
|
|
resp = client.get("/api/agent/v1/whoami", headers=_bearer())
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_scope_mismatch_returns_403(client, monkeypatch):
|
|
"""A valid token without B scope must NOT be able to submit backtests."""
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(agent_auth, "_lookup_token",
|
|
lambda raw: _fake_token_row(scopes="R"))
|
|
monkeypatch.setattr(agent_auth, "_touch_token_last_used", lambda *_: None)
|
|
monkeypatch.setattr(agent_auth, "_audit", lambda *a, **kw: None)
|
|
|
|
resp = client.post(
|
|
"/api/agent/v1/backtest/run",
|
|
headers=_bearer({"Content-Type": "application/json"}),
|
|
json={"code": "x", "market": "Crypto", "symbol": "BTC/USDT",
|
|
"timeframe": "1D", "start_date": "2024-01-01", "end_date": "2024-12-31"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_whoami_with_valid_token(client, monkeypatch):
|
|
"""Successful auth path should reach the route and return its payload."""
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(agent_auth, "_lookup_token",
|
|
lambda raw: _fake_token_row(scopes="R,B"))
|
|
monkeypatch.setattr(agent_auth, "_touch_token_last_used", lambda *_: None)
|
|
monkeypatch.setattr(agent_auth, "_audit", lambda *a, **kw: None)
|
|
|
|
resp = client.get("/api/agent/v1/whoami", headers=_bearer())
|
|
assert resp.status_code == 200
|
|
body = resp.get_json()
|
|
assert body["code"] == 0
|
|
data = body["data"]
|
|
assert data["agent_name"] == "test-agent"
|
|
assert "R" in data["scopes"] and "B" in data["scopes"]
|
|
assert data["paper_only"] is True
|
|
|
|
|
|
def test_rate_limit_returns_429(client, monkeypatch):
|
|
"""Per-token rate limit must trip with 429 once the bucket is full."""
|
|
agent_auth._schema_ready = True
|
|
monkeypatch.setattr(
|
|
agent_auth, "_lookup_token",
|
|
lambda raw: _fake_token_row(rate_limit_per_min=2),
|
|
)
|
|
monkeypatch.setattr(agent_auth, "_touch_token_last_used", lambda *_: None)
|
|
monkeypatch.setattr(agent_auth, "_audit", lambda *a, **kw: None)
|
|
|
|
headers = _bearer()
|
|
assert client.get("/api/agent/v1/whoami", headers=headers).status_code == 200
|
|
assert client.get("/api/agent/v1/whoami", headers=headers).status_code == 200
|
|
third = client.get("/api/agent/v1/whoami", headers=headers)
|
|
assert third.status_code == 429
|
|
body = third.get_json()
|
|
assert body["retriable"] is True
|
|
|
|
|
|
def test_token_generator_format():
|
|
"""Generated tokens must use the documented prefix and stable hash."""
|
|
token, prefix, token_hash = agent_auth.generate_token()
|
|
assert token.startswith(agent_auth.TOKEN_PREFIX)
|
|
assert prefix.startswith(agent_auth.TOKEN_PREFIX)
|
|
assert len(token_hash) == 64 # sha256 hex
|
|
# Same input → same hash; different inputs → different hashes.
|
|
assert agent_auth._hash_token(token) == token_hash
|
|
other_token, _, other_hash = agent_auth.generate_token()
|
|
assert token != other_token
|
|
assert token_hash != other_hash
|