"""HTTP surface: contracts, auth, validation and the hardening middleware."""
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
from asgi_lifespan import LifespanManager
from app.core.middleware import SECURITY_HEADERS
from app.core.settings import get_settings
from app.main import create_app
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
app = create_app()
async with LifespanManager(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
yield http
@pytest.fixture
def approval_headers() -> dict[str, str]:
"""A valid bearer header, read from the settings the app checks against.
Hardcoding the token made these tests pass locally and fail in CI, where the
workflow supplies a different `APPROVAL_TOKEN` — an environment variable
beats conftest's `setdefault`, so the app and the test disagreed and every
authenticated request came back 401. Deriving it keeps the suite hermetic
wherever it runs.
"""
return {"Authorization": f"Bearer {get_settings().approval_token}"}
class TestHealthAndDiscovery:
async def test_health_reports_configuration(self, client: httpx.AsyncClient) -> None:
response = await client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["engine"] in ("anthropic", "deterministic")
assert body["models"]["supervisor"]
assert body["watchlist"]
async def test_mcp_tools_are_documented_over_http(self, client: httpx.AsyncClient) -> None:
response = await client.get("/v1/mcp/tools")
assert response.status_code == 200
tools = response.json()
assert {tool["name"] for tool in tools} == {
"get_price_history",
"get_fundamentals",
"compute_metrics",
"fetch_rss_news",
}
async def test_event_kinds_are_published(self, client: httpx.AsyncClient) -> None:
response = await client.get("/v1/events/kinds")
assert response.status_code == 200
assert "mcp.tool_call" in response.json()
class TestSecurityHeaders:
async def test_every_response_is_hardened(self, client: httpx.AsyncClient) -> None:
response = await client.get("/health")
for header, value in SECURITY_HEADERS.items():
assert response.headers.get(header) == value
async def test_csp_forbids_script_execution(self, client: httpx.AsyncClient) -> None:
response = await client.get("/health")
assert "default-src 'none'" in response.headers["Content-Security-Policy"]
class TestInputValidation:
async def test_unknown_fields_are_rejected(self, client: httpx.AsyncClient) -> None:
response = await client.post("/v1/runs", json={"tickers": ["AAPL"], "sneaky": 1})
assert response.status_code == 422
@pytest.mark.parametrize(
"tickers",
[
[],
["A" * 40],
["AAPL; DROP TABLE runs"],
["../../etc/passwd"],
[""],
[f"TCK{index}" for index in range(50)],
["AAPL"] * 500,
],
)
async def test_hostile_watchlists_are_refused(
self, client: httpx.AsyncClient, tickers: list[str]
) -> None:
response = await client.post("/v1/runs", json={"tickers": tickers})
assert response.status_code == 422
async def test_duplicate_tickers_collapse_rather_than_fail(
self, client: httpx.AsyncClient
) -> None:
"""A user pasting the same symbol twice is a typo, not an attack."""
from app.api.schemas import CreateRunRequest
request = CreateRunRequest(tickers=["AAPL", "aapl", " AAPL "])
assert request.tickers == ["AAPL"]
async def test_validation_errors_do_not_echo_the_body(self, client: httpx.AsyncClient) -> None:
response = await client.post("/v1/runs", json={"tickers": ["