Skip to content

Commit 0a0b2ac

Browse files
author
Rudolf Schmidt
committed
test: Concurrency integration tests for middleware pipeline safety
Verifies request isolation, auth short-circuit, POST body isolation, and error routing under 50 concurrent requests with random delays.
1 parent 3ce8ef6 commit 0a0b2ac

File tree

16 files changed

+786
-0
lines changed

16 files changed

+786
-0
lines changed

tests/integration/concurrency/__init__.py

Whitespace-only changes.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Shared fixtures for concurrency integration tests.
2+
3+
Provides the FastAPI test app built from the fixture directory at
4+
tests/integration/fixtures/concurrency_app/. Each test gets a fresh
5+
copy of the routes in tmp_path for import isolation.
6+
7+
App structure:
8+
routes/_middleware.py # Root: stamps X-Request-ID, inits trace
9+
routes/echo/route.py # Echo: root middleware only
10+
routes/api/_middleware.py # API dir: appends "api" to trace
11+
routes/api/users/route.py # Users: file-level middleware
12+
routes/api/items/[item_id]/route.py # Items: handler-level middleware via route()
13+
routes/api/protected/_middleware.py # Auth: short-circuits 401
14+
routes/api/protected/route.py # Protected: returns authenticated user
15+
routes/api/messages/route.py # Messages: POST with JSON body
16+
routes/api/tasks/[task_id]/route.py # Tasks: raises 404 for "missing-*" IDs
17+
"""
18+
19+
import importlib.util
20+
import shutil
21+
from collections.abc import Callable
22+
from pathlib import Path
23+
24+
import pytest
25+
from fastapi import FastAPI
26+
27+
CONCURRENT_REQUESTS = 50
28+
29+
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "app"
30+
31+
EXPECTED_TRACES = {
32+
"echo": ["root"],
33+
"users": ["root", "api", "users-file"],
34+
"items": ["root", "api", "items-handler"],
35+
"protected": ["root", "api", "auth"],
36+
"messages": ["root", "api", "messages-file"],
37+
"tasks": ["root", "api"],
38+
}
39+
40+
41+
def _load_create_app() -> Callable[..., FastAPI]:
42+
"""Import create_app from the fixture's main.py by file path."""
43+
spec = importlib.util.spec_from_file_location(
44+
"concurrency_app.main", FIXTURE_DIR / "main.py"
45+
)
46+
assert spec and spec.loader
47+
module = importlib.util.module_from_spec(spec)
48+
spec.loader.exec_module(module)
49+
return module.create_app # type: ignore[no-any-return]
50+
51+
52+
_create_app = _load_create_app()
53+
54+
55+
@pytest.fixture
56+
def app(tmp_path: Path) -> FastAPI:
57+
"""Build a fresh FastAPI instance with routes copied into tmp_path."""
58+
routes_dir = tmp_path / "routes"
59+
shutil.copytree(FIXTURE_DIR / "routes", routes_dir)
60+
return _create_app(routes_dir)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Concurrency test app — a realistic FastAPI app with file-based routing.
2+
3+
Can be run standalone for manual testing:
4+
uvicorn tests.integration.fixtures.concurrency_app.main:app --reload
5+
6+
Routes exercise all three middleware layers with random delays (50ms–1s)
7+
to surface data leaks and request isolation issues under concurrent load.
8+
"""
9+
10+
from pathlib import Path
11+
12+
from fastapi import FastAPI
13+
14+
from fastapi_filebased_routing import create_router_from_path
15+
16+
ROUTES_DIR = Path(__file__).parent / "routes"
17+
18+
19+
def create_app(routes_dir: Path = ROUTES_DIR) -> FastAPI:
20+
"""Build a FastAPI instance wired to the given routes directory."""
21+
application = FastAPI(title="Concurrency Test App")
22+
router = create_router_from_path(routes_dir)
23+
application.include_router(router)
24+
return application
25+
26+
27+
app = create_app()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Root middleware — stamps request ID and initializes middleware trace."""
2+
3+
4+
async def middleware(request, call_next): # type: ignore[no-untyped-def]
5+
request_id = request.headers.get("x-request-id", "missing")
6+
request.state.request_id = request_id
7+
request.state.middleware_trace = ["root"]
8+
response = await call_next(request)
9+
response.headers["X-Request-ID"] = request.state.request_id
10+
response.headers["X-Middleware-Trace"] = ",".join(request.state.middleware_trace)
11+
return response
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""API directory middleware — appends 'api' to the middleware trace."""
2+
3+
4+
async def middleware(request, call_next): # type: ignore[no-untyped-def]
5+
request.state.middleware_trace.append("api")
6+
return await call_next(request)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Items route — handler-level middleware via route metaclass + random delay."""
2+
3+
import asyncio
4+
import random
5+
6+
from fastapi import Request
7+
8+
from fastapi_filebased_routing.core.middleware import route
9+
10+
11+
async def _handler_middleware(request, call_next): # type: ignore[no-untyped-def]
12+
request.state.middleware_trace.append("items-handler")
13+
return await call_next(request)
14+
15+
16+
class get(route): # noqa: N801
17+
middleware = [_handler_middleware]
18+
19+
async def handler(request: Request, item_id: str): # type: ignore[no-untyped-def] # noqa: N805
20+
await asyncio.sleep(random.uniform(0.05, 1.0))
21+
return {
22+
"request_id": request.state.request_id,
23+
"item_id": item_id,
24+
"trace": request.state.middleware_trace,
25+
"route": "items",
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Messages route — accepts POST with JSON body, echoes it back.
2+
3+
Tests request body isolation: under concurrency, Request A's body
4+
must never appear in Request B's response.
5+
"""
6+
7+
import asyncio
8+
import random
9+
10+
from fastapi import Request
11+
from pydantic import BaseModel
12+
13+
14+
class _MessageBody(BaseModel):
15+
sender: str
16+
content: str
17+
18+
19+
async def _file_middleware(request, call_next): # type: ignore[no-untyped-def]
20+
request.state.middleware_trace.append("messages-file")
21+
return await call_next(request)
22+
23+
24+
middleware = [_file_middleware]
25+
26+
27+
async def post(request: Request, body: _MessageBody): # type: ignore[no-untyped-def]
28+
await asyncio.sleep(random.uniform(0.05, 1.0))
29+
return {
30+
"request_id": request.state.request_id,
31+
"sender": body.sender,
32+
"content": body.content,
33+
"trace": request.state.middleware_trace,
34+
"route": "messages",
35+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Auth middleware — short-circuits with 401 for missing/invalid tokens.
2+
3+
This is the security-critical middleware: under concurrency, an authenticated
4+
response must NEVER leak to an unauthenticated caller, and vice versa.
5+
"""
6+
7+
from starlette.responses import JSONResponse
8+
9+
10+
async def middleware(request, call_next): # type: ignore[no-untyped-def]
11+
token = request.headers.get("authorization", "")
12+
if not token.startswith("Bearer "):
13+
return JSONResponse(
14+
{"error": "unauthorized", "request_id": request.state.request_id},
15+
status_code=401,
16+
)
17+
request.state.user_id = token.removeprefix("Bearer ")
18+
request.state.middleware_trace.append("auth")
19+
return await call_next(request)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Protected route — returns the authenticated user's identity.
2+
3+
Only reachable if the auth middleware in _middleware.py allows the request through.
4+
"""
5+
6+
import asyncio
7+
import random
8+
9+
from fastapi import Request
10+
11+
12+
async def get(request: Request): # type: ignore[no-untyped-def]
13+
await asyncio.sleep(random.uniform(0.05, 1.0))
14+
return {
15+
"request_id": request.state.request_id,
16+
"user_id": request.state.user_id,
17+
"trace": request.state.middleware_trace,
18+
"route": "protected",
19+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Tasks route — raises HTTPException for specific task IDs.
2+
3+
Tests error handling under concurrency: when handler A raises 404,
4+
handler B (running concurrently) must still get its correct 200 response.
5+
Task IDs starting with "missing-" trigger a 404 Not Found.
6+
"""
7+
8+
import asyncio
9+
import random
10+
11+
from fastapi import HTTPException, Request
12+
13+
14+
async def get(request: Request, task_id: str): # type: ignore[no-untyped-def]
15+
await asyncio.sleep(random.uniform(0.05, 1.0))
16+
17+
if task_id.startswith("missing-"):
18+
raise HTTPException(
19+
status_code=404,
20+
detail=f"Task {task_id} not found",
21+
)
22+
23+
return {
24+
"request_id": request.state.request_id,
25+
"task_id": task_id,
26+
"trace": request.state.middleware_trace,
27+
"route": "tasks",
28+
}

0 commit comments

Comments
 (0)