diff --git a/pyproject.toml b/pyproject.toml index 6042008..6d962f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,3 +58,13 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = ["app"] + +[tool.uv] +# FastAPI >=0.137 changed how included routers' routes are resolved, which +# breaks slowapi's SlowAPIMiddleware enforcement of `default_limits` on +# undecorated routes mounted via include_router (verified here: the global +# rate-limit canary fails on 0.140.x and passes on 0.135.x). Known upstream +# as slowapi issue #281 with fix PR #282; drop this hold once a slowapi +# release containing that fix ships. Starlette is capped alongside it since +# slowapi has never been validated against Starlette 1.x. +constraint-dependencies = ["fastapi<0.136", "starlette<1.0"] diff --git a/tests/conftest.py b/tests/conftest.py index 400374c..9586179 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,17 @@ async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +@pytest.fixture(autouse=True) +def reset_rate_limiter(): + """Clear rate-limit buckets between tests so request counts don't leak + across tests (the limiter storage is process-global).""" + from app.rate_limit import limiter + + limiter._storage.reset() + yield + limiter._storage.reset() + + @pytest.fixture(autouse=True) async def setup_database(): async with engine.begin() as conn: diff --git a/tests/test_rate_limiting.py b/tests/test_rate_limiting.py new file mode 100644 index 0000000..3ac52cd --- /dev/null +++ b/tests/test_rate_limiting.py @@ -0,0 +1,27 @@ +"""Global rate limiting behaviour. + +The Limiter's ``default_limits`` (60/min, enforced by SlowAPIMiddleware) +must apply to every route that isn't decorated or exempt, and the +infrastructure routes must stay exempt. This is the canary for the class +of regression where a framework upgrade silently disables middleware +enforcement (see slowapi issue #281 for fastapi>=0.137). +""" + +from httpx import AsyncClient + + +class TestGlobalRateLimit: + async def test_default_limit_enforced_on_undecorated_route(self, client: AsyncClient): + # /v1/armor has no explicit @limiter.limit, so it gets the 60/min + # default. (Matches `default_limits` in app/rate_limit.py — bump + # both together if that changes.) + for _ in range(60): + assert (await client.get("/v1/armor")).status_code == 200 + assert (await client.get("/v1/armor")).status_code == 429 + + async def test_infrastructure_routes_are_exempt(self, client: AsyncClient): + # / and /health are @limiter.exempt — many rapid hits (well past + # the 60/min default) never 429. + for _ in range(70): + assert (await client.get("/health")).status_code == 200 + assert (await client.get("/")).status_code == 200 diff --git a/uv.lock b/uv.lock index 11daecb..edd3b02 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,12 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[manifest] +constraints = [ + { name = "fastapi", specifier = "<0.136" }, + { name = "starlette", specifier = "<1.0" }, +] + [[package]] name = "aiosqlite" version = "0.22.1"