c835996bd6
Three pure-ASGI middlewares close completeness-review gaps §3.1.4 (no body/rate limits) and §3.1.25 (no security headers): - BodySizeLimitMiddleware — rejects oversized uploads (50 MB default, CYCLONE_MAX_BODY_BYTES override). 413 on over-cap Content-Length and on chunked reads that cross the cap. - RateLimitMiddleware — sliding-window per-IP limiter (300/min default, CYCLONE_RATE_LIMIT_PER_MIN override). 429 over the window. /api/health is exempt. - SecurityHeadersMiddleware — stamps X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and a strict Content-Security-Policy on every response. Every 413/429 also writes a tamper-evident api.request_rejected event into the SP11 audit chain so an operator can correlate rejections with the SP18 JSON logs. GET /api/health is rewritten to return a subsystem snapshot: DB connectivity (SELECT 1), MFT scheduler state, backup scheduler state, live pubsub subscriber counts, last batch id + timestamp. Returns status='degraded' if any subsystem is unhappy; per-subsystem errors surfaced in the respective dict. Cyclone.pubsub.EventBus.stats() — new method for live subscriber counts. 13 new tests (test_security.py) + 1 updated (test_api.py health endpoint). All 883 backend tests pass.
226 lines
7.1 KiB
Python
226 lines
7.1 KiB
Python
"""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
|