"""SP19 — Security middleware + health probe tests. Covers each middleware in isolation (using FastAPI's TestClient with a minimal app) and the integration into the real ``cyclone.api`` app (headers present on every response, /api/health returns the rich snapshot). """ from __future__ import annotations import asyncio from typing import Any, Callable import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient from cyclone.security import ( DEFAULT_MAX_BODY_BYTES, DEFAULT_RATE_LIMIT_PER_MIN, BodySizeLimitMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware, get_health_snapshot, ) # --------------------------------------------------------------------------- # Test apps — built inline per-test to avoid state pollution between tests. # --------------------------------------------------------------------------- def _echo_app_with( *middlewares: Callable[..., Any], ) -> FastAPI: """Build a tiny FastAPI app with the given middleware chain. ``middlewares`` are listed outermost-first (the first element runs first on the request). Starlette's ``add_middleware`` *prepends*, so we add in reverse to preserve "outermost first" in the public API. """ app = FastAPI() @app.post("/echo") async def echo(request: Request): body = await request.body() return {"received_bytes": len(body)} @app.get("/api/health") async def h(): return {"status": "ok"} for mw in reversed(middlewares): app.add_middleware(mw) return app # --------------------------------------------------------------------------- # BodySizeLimitMiddleware # --------------------------------------------------------------------------- def test_body_size_accepts_under_limit(): """500 bytes is under the 1024-byte limit; body passes through.""" app = _echo_app_with( SecurityHeadersMiddleware, lambda a: BodySizeLimitMiddleware(a, max_bytes=1024), ) client = TestClient(app) resp = client.post("/echo", content=b"x" * 500) assert resp.status_code == 200, resp.text assert resp.json() == {"received_bytes": 500} def test_body_size_rejects_over_content_length(): """A 200-byte body against a 100-byte cap returns 413.""" app = _echo_app_with( SecurityHeadersMiddleware, lambda a: BodySizeLimitMiddleware(a, max_bytes=100), ) client = TestClient(app) resp = client.post("/echo", content=b"x" * 200) assert resp.status_code == 413 body = resp.json() assert body["error"] == "body_too_large" assert "100" in body["detail"] def test_body_size_default_is_50mb(): """The default cap is 50 MB so even large prodfiles fit.""" assert DEFAULT_MAX_BODY_BYTES == 50 * 1024 * 1024 def test_body_size_rejects_bad_content_length(): """A non-integer Content-Length is rejected as 400, not 500.""" sent: list = [] inner_app = FastAPI() @inner_app.post("/echo") async def echo(request: Request): return {"ok": True} app_instance = BodySizeLimitMiddleware(inner_app, max_bytes=100) async def fake_receive(): return {"type": "http.request", "body": b"", "more_body": False} async def fake_send(msg): sent.append(msg) scope = { "type": "http", "method": "POST", "path": "/echo", "headers": [(b"content-length", b"not-a-number")], "query_string": b"", } asyncio.run(app_instance(scope, fake_receive, fake_send)) start = next(m for m in sent if m["type"] == "http.response.start") assert start["status"] == 400 # --------------------------------------------------------------------------- # RateLimitMiddleware # --------------------------------------------------------------------------- def test_rate_limit_default_is_300_per_min(): assert DEFAULT_RATE_LIMIT_PER_MIN == 300 def test_rate_limit_allows_under_threshold(): """A few requests under the per-minute limit are allowed.""" app = _echo_app_with( SecurityHeadersMiddleware, lambda a: RateLimitMiddleware(a, per_minute=5), ) client = TestClient(app) for _ in range(5): resp = client.post("/echo", content=b"x") assert resp.status_code == 200, resp.text def test_rate_limit_blocks_over_threshold(): """The 6th request within a 60s window is rate-limited.""" app = _echo_app_with( SecurityHeadersMiddleware, lambda a: RateLimitMiddleware(a, per_minute=3), ) client = TestClient(app) for _ in range(3): assert client.post("/echo", content=b"x").status_code == 200 resp = client.post("/echo", content=b"x") assert resp.status_code == 429 assert resp.json()["error"] == "rate_limited" def test_rate_limit_exempts_health_probes(): """A load balancer hammering /api/health should not trip the limiter.""" app = _echo_app_with( SecurityHeadersMiddleware, lambda a: RateLimitMiddleware(a, per_minute=2), ) client = TestClient(app) for _ in range(20): assert client.get("/api/health").status_code == 200 assert client.post("/echo", content=b"x").status_code == 200 # --------------------------------------------------------------------------- # SecurityHeadersMiddleware # --------------------------------------------------------------------------- def test_security_headers_present_on_200(): app = _echo_app_with(SecurityHeadersMiddleware) client = TestClient(app) resp = client.get("/api/health") assert resp.headers["X-Content-Type-Options"] == "nosniff" assert resp.headers["X-Frame-Options"] == "DENY" assert resp.headers["Referrer-Policy"] == "same-origin" assert "default-src 'none'" in resp.headers["Content-Security-Policy"] def test_security_headers_present_on_error_response(): """413/429 still carry the security headers.""" # Define a tiny factory so Starlette can introspect it as a class. class _BoundBodySize(BodySizeLimitMiddleware): def __init__(self, app): # type: ignore[no-untyped-def] super().__init__(app, max_bytes=10) app = _echo_app_with(SecurityHeadersMiddleware, _BoundBodySize) client = TestClient(app) resp = client.post("/echo", content=b"x" * 100) assert resp.status_code == 413, resp.text assert resp.headers["X-Content-Type-Options"] == "nosniff" # --------------------------------------------------------------------------- # get_health_snapshot # --------------------------------------------------------------------------- def test_health_snapshot_basic_shape(): snap = get_health_snapshot() d = snap.to_dict() assert "status" in d assert "version" in d assert "db" in d assert "scheduler" in d assert "pubsub" in d assert "batch" in d def test_health_snapshot_db_ok_in_tests(): """The conftest DB fixture is live; ``SELECT 1`` works.""" snap = get_health_snapshot() assert snap.db.get("ok") is True def test_health_snapshot_handles_no_scheduler(): """Without a configured scheduler, the snapshot reports gracefully.""" snap = get_health_snapshot() sched = snap.scheduler assert "running" in sched or "configured" in sched