"""Tests for unknown parameter detection middleware (X-Ignored-Params header).""" import pytest from fastapi import FastAPI, Query from fastapi.testclient import TestClient from pydantic import BaseModel def _make_test_app() -> FastAPI: """Create a minimal FastAPI app with the unknown params middleware.""" import json import logging app = FastAPI() logger = logging.getLogger(__name__) @app.middleware("http") async def unknown_params_middleware(request, call_next): from starlette.routing import Match ignored_params: list[str] = [] if request.query_params: for route in app.routes: match, _ = route.matches(request.scope) if match == Match.FULL: endpoint = getattr(route, "endpoint", None) if endpoint: import inspect sig = inspect.signature(endpoint) declared = set(sig.parameters.keys()) path_params = set(getattr(route, "param_convertors", {}).keys()) | set( request.path_params.keys() ) known_query = declared - path_params for name in request.query_params: if name not in known_query and name not in path_params: ignored_params.append(name) break body_ignored: list[str] = [] content_type = request.headers.get("content-type", "") if request.method in ("POST", "PUT", "PATCH") and "application/json" in content_type: try: body_bytes = await request.body() if body_bytes: body_json = json.loads(body_bytes) if isinstance(body_json, dict): for route in app.routes: match, _ = route.matches(request.scope) if match == Match.FULL: endpoint = getattr(route, "endpoint", None) if endpoint: import inspect sig = inspect.signature(endpoint) for param in sig.parameters.values(): ann = param.annotation if isinstance(ann, type) and issubclass(ann, BaseModel): known_fields = set(ann.model_fields.keys()) for key in body_json: if key not in known_fields: body_ignored.append(key) break break except (json.JSONDecodeError, UnicodeDecodeError): pass all_ignored = ignored_params + body_ignored response = await call_next(request) if all_ignored: ignored_str = ", ".join(all_ignored) logger.warning( "Unknown parameters ignored: [%s] for %s %s", ignored_str, request.method, request.url.path, ) response.headers["X-Ignored-Params"] = ignored_str return response class ItemRequest(BaseModel): name: str value: int = 0 @app.get("/items") async def list_items(limit: int = 10, offset: int = 0): return {"items": [], "limit": limit, "offset": offset} @app.get("/items/{item_id}") async def get_item(item_id: str, details: bool = False): return {"id": item_id, "details": details} @app.post("/items") async def create_item(request: ItemRequest): return {"name": request.name, "value": request.value} return app @pytest.fixture def client(): return TestClient(_make_test_app()) class TestUnknownQueryParams: def test_known_params_no_header(self, client): resp = client.get("/items", params={"limit": 5, "offset": 0}) assert resp.status_code == 200 assert "X-Ignored-Params" not in resp.headers def test_unknown_query_param_sets_header(self, client): resp = client.get("/items", params={"limit": 5, "tag": "foo"}) assert resp.status_code == 200 assert "X-Ignored-Params" in resp.headers assert "tag" in resp.headers["X-Ignored-Params"] def test_multiple_unknown_query_params(self, client): resp = client.get("/items", params={"limit": 5, "tag": "foo", "created_after": "2024-01-01"}) assert resp.status_code == 200 ignored = resp.headers["X-Ignored-Params"] assert "tag" in ignored assert "created_after" in ignored def test_path_params_not_flagged(self, client): resp = client.get("/items/abc123", params={"details": "true"}) assert resp.status_code == 200 assert "X-Ignored-Params" not in resp.headers def test_unknown_with_path_param(self, client): resp = client.get("/items/abc123", params={"details": "true", "unknown": "x"}) assert resp.status_code == 200 assert "X-Ignored-Params" in resp.headers assert "unknown" in resp.headers["X-Ignored-Params"] def test_no_query_params_no_header(self, client): resp = client.get("/items") assert resp.status_code == 200 assert "X-Ignored-Params" not in resp.headers class TestUnknownBodyFields: def test_known_body_fields_no_header(self, client): resp = client.post("/items", json={"name": "test", "value": 42}) assert resp.status_code == 200 assert "X-Ignored-Params" not in resp.headers def test_unknown_body_field_sets_header(self, client): resp = client.post("/items", json={"name": "test", "value": 42, "extra_field": "surprise"}) assert resp.status_code == 200 assert "X-Ignored-Params" in resp.headers assert "extra_field" in resp.headers["X-Ignored-Params"] def test_multiple_unknown_body_fields(self, client): resp = client.post("/items", json={"name": "test", "foo": 1, "bar": 2}) assert resp.status_code == 200 ignored = resp.headers["X-Ignored-Params"] assert "foo" in ignored assert "bar" in ignored