Files
Auto-Trader/agent/tests/test_system_routes.py
T
Haozhe Wu 4e3c91c77a security: address all 10 findings from external audit (issue #476)
Implements VT-001 through VT-010 from the 2026-07-10 external security
audit (Trivy/Semgrep/ZAP/Gitleaks/pip-audit against a real deployment):

- VT-001 (Critical): AST hardening in backtest/runner.py rejects
  network/subprocess/eval/os.environ/unsafe-open calls reachable from
  generated SignalEngine method bodies (previously only checked
  top-level statements). Defense-in-depth: ephemeral per-run HOME,
  RLIMIT_AS/NOFILE caps, best-effort UID-drop to a new vibe-sandbox
  user (currently inert under Docker's cap model - see session doc;
  accepted as documented residual gap, primary AST defense unaffected).
- VT-002: multi-stage Dockerfile split so build-essential never
  reaches the runtime image.
- VT-003: SSE auth moved from a long-lived API key in the URL query
  string to short-lived single-use tickets; access-log redaction for
  any remaining query secrets.
- VT-004: agent/.env split-brain fixed (bind mount + atomic 0600
  writes) between docker-compose and Web UI settings writes.
- VT-005: /correlation now requires auth + per-IP rate limit; generic
  exceptions no longer leak raw text to clients.
- VT-006: CSP-Report-Only + security headers middleware; Google Fonts
  replaced with self-hosted @fontsource packages.
- VT-007: docker-compose hardening (cap_drop ALL + minimal cap_add,
  no-new-privileges, read_only rootfs, resource limits).
- VT-008: /health split into /live (liveness) and /ready (real
  provider-config readiness check); /health kept as a compat alias.
- VT-009: base images pinned by digest, GitHub Actions pinned by
  commit SHA, dependabot.yml added, hash-verified requirements-lock.txt
  now actually wired into the Docker build via --require-hashes.
- VT-010: universe-panel pickle cache sidecar upgraded from an
  unkeyed SHA-256 (forgeable by anyone with cache-dir write access)
  to a keyed HMAC.

Implemented via a 5-parcel Workflow (file-disjoint, independent
adversarial verification per parcel, then a holistic final audit).
The final audit caught a real regression (CI env-var-gate violation)
and confirmed a dependency-pinning gap, both fixed and re-verified
here, including a latent transitive-dependency bug the fix surfaced
(smartmoneyconcepts<0.0.27 pulls an unbuildable zigzag package) - see
docs/sessions/2026-07-13_session_vt476_security_hardening.md (local,
gitignored) for full evidence trail.

Full suite: 5191 passed, 6 pre-existing/environmental failures
(unchanged from main). Docker build + hardened compose boot verified
end-to-end.
2026-07-13 01:40:53 +08:00

207 lines
7.7 KiB
Python

"""Route-level contracts for the system endpoints.
Exercises the REST surface mounted by ``register_system_routes``:
``/live`` + ``/ready`` (VT-008 liveness/readiness split) and the hardened
``/correlation`` endpoint (VT-005: auth + per-IP rate limit + masked errors).
Each test drives the app through ``TestClient``. The default ``TestClient``
client host (``testclient``) and explicit ``127.0.0.1`` are treated as loopback
callers, so ``require_auth`` passes without a configured API key; a non-loopback
client with a configured key is used to prove the auth gate rejects.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
import api_server
from src.api import system_routes
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def local_client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
"""Loopback client with no API key configured (dev-mode: auth passes)."""
monkeypatch.delenv("API_AUTH_KEY", raising=False)
monkeypatch.setattr(api_server, "_API_KEY", "")
return TestClient(api_server.app, client=("127.0.0.1", 50000))
@pytest.fixture(autouse=True)
def _reset_correlation_limiter() -> None:
"""Clear the module-level rate limiter so tests never leak hits into each other."""
system_routes._correlation_rate_limiter.reset()
# ---------------------------------------------------------------------------
# VT-008 — /live, /health alias, /ready
# ---------------------------------------------------------------------------
def test_live_returns_healthy(local_client: TestClient):
resp = local_client.get("/live")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "healthy"
assert body["service"] == "Vibe-Trading API"
def test_health_is_backward_compatible_alias(local_client: TestClient):
resp = local_client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "healthy"
def test_ready_returns_200_when_provider_ready(
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(system_routes, "_provider_readiness", lambda: (True, "ready"))
resp = local_client.get("/ready")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ready"
assert body["service"] == "Vibe-Trading API"
def test_ready_returns_503_when_provider_not_ready(
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(
system_routes,
"_provider_readiness",
lambda: (False, "LLM provider not configured"),
)
resp = local_client.get("/ready")
assert resp.status_code == 503
assert resp.json()["detail"] == "LLM provider not configured"
def test_readiness_helper_real_missing_credential(monkeypatch: pytest.MonkeyPatch):
"""The un-mocked helper flags a configured provider with no credential."""
import src.providers.llm as llm
from src.config.accessor import reset_env_config
# Neutralize the .env reload inside _sync_provider_env so the test fully
# controls the environment (otherwise a machine-local OPENAI_API_KEY leaks in).
monkeypatch.setattr(llm, "_ensure_dotenv", lambda: None)
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
reset_env_config()
ready, reason = system_routes._provider_readiness()
assert ready is False
assert "credential" in reason.lower()
def test_readiness_helper_real_ready(monkeypatch: pytest.MonkeyPatch):
"""The un-mocked helper reports ready when provider+model+key are present."""
from src.config.accessor import reset_env_config
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "gpt-4o-mini")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-a-real-key")
reset_env_config()
ready, reason = system_routes._provider_readiness()
assert ready is True
assert reason == "ready"
def test_readiness_helper_missing_provider(monkeypatch: pytest.MonkeyPatch):
from src.config.accessor import reset_env_config
monkeypatch.setenv("LANGCHAIN_PROVIDER", "")
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "")
reset_env_config()
ready, reason = system_routes._provider_readiness()
assert ready is False
assert "provider" in reason.lower()
# ---------------------------------------------------------------------------
# VT-005 — /correlation auth + rate limit + error masking
# ---------------------------------------------------------------------------
def test_correlation_requires_auth_for_remote_client(monkeypatch: pytest.MonkeyPatch):
"""A non-loopback caller with a configured key but no token is rejected."""
monkeypatch.setattr(api_server, "_API_KEY", "server-secret")
remote = TestClient(api_server.app, client=("203.0.113.9", 51000))
resp = remote.get("/correlation", params={"codes": "AAPL,SPY"})
assert resp.status_code == 401
def test_correlation_allows_local_and_masks_generic_error(
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
"""A generic backend failure must not leak its exception text to the client."""
import backtest.correlation as corr
def _boom(**_kwargs):
raise RuntimeError("sensitive internal detail: db=prod host=10.0.0.5")
monkeypatch.setattr(corr, "compute_correlation_matrix", _boom)
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
assert resp.status_code == 500
detail = resp.json()["detail"]
assert detail == "Correlation computation failed"
assert "sensitive internal detail" not in detail
def test_correlation_value_error_still_surfaces_message(
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
"""Hand-raised validation errors remain user-facing (unchanged behavior)."""
import backtest.correlation as corr
def _bad(**_kwargs):
raise ValueError("Not enough overlapping history")
monkeypatch.setattr(corr, "compute_correlation_matrix", _bad)
resp = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
assert resp.status_code == 400
assert resp.json()["detail"] == "Not enough overlapping history"
def test_correlation_rate_limit_returns_429(
local_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
"""Exceeding the per-client window yields 429 with a generic message."""
import backtest.correlation as corr
monkeypatch.setattr(
corr,
"compute_correlation_matrix",
lambda **_kwargs: {"labels": ["AAPL", "SPY"], "matrix": [[1.0, 0.5], [0.5, 1.0]]},
)
# Tighten the limiter to make the boundary cheap and deterministic.
monkeypatch.setattr(
system_routes,
"_correlation_rate_limiter",
system_routes._SlidingWindowRateLimiter(max_requests=2, window_seconds=60.0),
)
ok1 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
ok2 = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
blocked = local_client.get("/correlation", params={"codes": "AAPL,SPY"})
assert ok1.status_code == 200
assert ok2.status_code == 200
assert blocked.status_code == 429
assert blocked.json()["detail"] == "Rate limit exceeded, try again later"
def test_correlation_rate_limiter_is_per_client(monkeypatch: pytest.MonkeyPatch):
"""Distinct client IPs get independent budgets."""
limiter = system_routes._SlidingWindowRateLimiter(max_requests=1, window_seconds=60.0)
assert limiter.allow("1.1.1.1") is True
assert limiter.allow("1.1.1.1") is False
assert limiter.allow("2.2.2.2") is True