fix(test-schemathesis): disable rate limiter in fuzz subprocess
Schemathesis fires up to 3000 examples per endpoint. POST /auth/login caps at 10/5min per IP, so the second example onward returns 429 and the positive_data_acceptance check flags it as RejectedPositiveData (its allowed-status list is hardcoded in schemathesis to 2xx/401/403/404/409/5xx, so OpenAPI tweaks can't fix it). DECNET_LIMITER_ENABLED=false exists for exactly this case (see limiter.py docstring on stress/load testing). Reverts the custom_openapi shim from5d88346/9b1168c— the endpoint already declares 429 in its responses= map (api_login.py:38), and the shim turned out to address a problem that wasn't there. Drop the companion test along with it.
This commit is contained in:
@@ -258,89 +258,6 @@ if DECNET_PROFILE_REQUESTS:
|
|||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
def _rate_limited_endpoint_names() -> set[str]:
|
|
||||||
"""Return ``{module}.{qualname}`` for every endpoint slowapi tracks.
|
|
||||||
|
|
||||||
slowapi tags each ``@limiter.limit(...)``-decorated function under
|
|
||||||
these two registries. We do NOT advertise 429 on undecorated routes:
|
|
||||||
SlowAPIMiddleware only consults the registries, so an endpoint
|
|
||||||
without a decorator can never legitimately return 429, and
|
|
||||||
documenting it would mislead clients.
|
|
||||||
"""
|
|
||||||
names: set[str] = set()
|
|
||||||
names.update(getattr(limiter, "_route_limits", {}).keys())
|
|
||||||
names.update(getattr(limiter, "_dynamic_route_limits", {}).keys())
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_openapi() -> dict:
|
|
||||||
"""Inject 429 into the OpenAPI spec for rate-limited operations only.
|
|
||||||
|
|
||||||
SlowAPI returns 429 from ``@limiter.limit(...)``-decorated routes
|
|
||||||
(currently just ``POST /api/v1/auth/login``). Without 429 in the
|
|
||||||
spec, schemathesis flags legitimate rate-limit responses as
|
|
||||||
status-code-nonconformant. We restrict the injection to actually
|
|
||||||
rate-limited endpoints so the spec stays honest.
|
|
||||||
"""
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
if app.openapi_schema:
|
|
||||||
return app.openapi_schema
|
|
||||||
schema = get_openapi(
|
|
||||||
title=app.title,
|
|
||||||
version=app.version,
|
|
||||||
routes=app.routes,
|
|
||||||
description=app.description,
|
|
||||||
)
|
|
||||||
|
|
||||||
rate_limited = _rate_limited_endpoint_names()
|
|
||||||
if not rate_limited:
|
|
||||||
app.openapi_schema = schema
|
|
||||||
return schema
|
|
||||||
|
|
||||||
too_many = {
|
|
||||||
"description": "Rate limit exceeded",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"error": {"type": "string"}},
|
|
||||||
"required": ["error"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build {(path, method): endpoint_qualname} from the live route table.
|
|
||||||
route_index: dict[tuple[str, str], str] = {}
|
|
||||||
for route in app.routes:
|
|
||||||
if not isinstance(route, APIRoute):
|
|
||||||
continue
|
|
||||||
endpoint = route.endpoint
|
|
||||||
# slowapi unwraps decorated functions but keeps the original
|
|
||||||
# __module__/__name__ via functools.wraps, so this matches.
|
|
||||||
qualname = f"{endpoint.__module__}.{endpoint.__name__}"
|
|
||||||
for method in route.methods or ():
|
|
||||||
route_index[(route.path, method.lower())] = qualname
|
|
||||||
|
|
||||||
for path, path_item in schema.get("paths", {}).items():
|
|
||||||
for method, op in path_item.items():
|
|
||||||
if method.lower() not in {
|
|
||||||
"get", "post", "put", "patch", "delete", "options", "head",
|
|
||||||
}:
|
|
||||||
continue
|
|
||||||
qualname = route_index.get((path, method.lower()))
|
|
||||||
if qualname and qualname in rate_limited:
|
|
||||||
op.setdefault("responses", {}).setdefault("429", too_many)
|
|
||||||
|
|
||||||
app.openapi_schema = schema
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
app.openapi = _custom_openapi # type: ignore[method-assign]
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> ORJSONResponse:
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> ORJSONResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ def start_automated_server() -> subprocess.Popen:
|
|||||||
env["DECNET_DEVELOPER"] = "true"
|
env["DECNET_DEVELOPER"] = "true"
|
||||||
env["DECNET_CONTRACT_TEST"] = "true"
|
env["DECNET_CONTRACT_TEST"] = "true"
|
||||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||||
|
# Schemathesis fires thousands of examples per endpoint; the login
|
||||||
|
# bucket (10/5min per IP) trips on the second example and turns
|
||||||
|
# every subsequent valid request into a RejectedPositiveData
|
||||||
|
# failure. Disable the limiter for the fuzz subprocess — same
|
||||||
|
# rationale as the load-testing knob in decnet/web/limiter.py.
|
||||||
|
env["DECNET_LIMITER_ENABLED"] = "false"
|
||||||
|
|
||||||
log_dir = Path(__file__).parent.parent.parent / "logs"
|
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||||
log_dir.mkdir(exist_ok=True)
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""OpenAPI must advertise 429 on every slowapi-rate-limited operation.
|
|
||||||
|
|
||||||
Other endpoints may also advertise 429 for their own reasons (e.g. the
|
|
||||||
SSE connection cap in ``decnet.web.sse_limits``); the test does not
|
|
||||||
forbid those — it only enforces the slowapi side.
|
|
||||||
"""
|
|
||||||
from decnet.web.api import app, _rate_limited_endpoint_names
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
|
|
||||||
def _route_qualname_index() -> dict[tuple[str, str], str]:
|
|
||||||
idx: dict[tuple[str, str], str] = {}
|
|
||||||
for route in app.routes:
|
|
||||||
if not isinstance(route, APIRoute):
|
|
||||||
continue
|
|
||||||
qn = f"{route.endpoint.__module__}.{route.endpoint.__name__}"
|
|
||||||
for method in route.methods or ():
|
|
||||||
idx[(route.path, method.lower())] = qn
|
|
||||||
return idx
|
|
||||||
|
|
||||||
|
|
||||||
def test_429_documented_on_rate_limited_endpoints_only() -> None:
|
|
||||||
schema = app.openapi()
|
|
||||||
paths = schema.get("paths", {})
|
|
||||||
assert paths, "OpenAPI schema is empty — router not mounted"
|
|
||||||
|
|
||||||
rate_limited = _rate_limited_endpoint_names()
|
|
||||||
assert rate_limited, "no @limiter.limit-decorated endpoints found"
|
|
||||||
|
|
||||||
qualname_for = _route_qualname_index()
|
|
||||||
|
|
||||||
http_methods = {"get", "post", "put", "patch", "delete", "options", "head"}
|
|
||||||
missing: list[str] = []
|
|
||||||
|
|
||||||
for path, item in paths.items():
|
|
||||||
for method, op in item.items():
|
|
||||||
if method.lower() not in http_methods:
|
|
||||||
continue
|
|
||||||
qn = qualname_for.get((path, method.lower()))
|
|
||||||
if qn in rate_limited and "429" not in op.get("responses", {}):
|
|
||||||
missing.append(f"{method.upper()} {path}")
|
|
||||||
|
|
||||||
assert not missing, f"rate-limited ops missing 429: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_endpoint_documents_429() -> None:
|
|
||||||
"""Sanity check the one endpoint we know is rate-limited."""
|
|
||||||
schema = app.openapi()
|
|
||||||
op = schema["paths"]["/api/v1/auth/login"]["post"]
|
|
||||||
assert "429" in op["responses"]
|
|
||||||
Reference in New Issue
Block a user