chore: merge executor worktree (06-05) — trusted-proxy rate limiting + per-account limiter
Resolved merge conflicts: - backend/main.py: keep both setup_logging (06-02) and account_limiter (06-05) imports - backend/tests/test_rate_limiting.py: take 06-05 version (all 8 xfails promoted) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,22 @@ async def async_client(db_session: AsyncSession):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── Rate limiter reset — prevents cross-test contamination ───────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limiter():
|
||||
"""Reset the in-memory rate limiter storage before each test.
|
||||
|
||||
The account_limiter is a module-level singleton using MemoryStorage.
|
||||
Without this fixture, rate limit counters accumulate across tests in the
|
||||
same process and cause unrelated tests to receive 429 responses.
|
||||
"""
|
||||
from services.rate_limiting import account_limiter
|
||||
account_limiter._storage.reset()
|
||||
yield
|
||||
account_limiter._storage.reset()
|
||||
|
||||
|
||||
# ── File fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,84 +1,193 @@
|
||||
"""
|
||||
Rate limiting test stubs — D-11, D-12, Assumption A1.
|
||||
Rate limiting tests — D-11, D-12, Assumption A1.
|
||||
|
||||
Wave 0 xfail scaffold for Phase 6 plan 06-04.
|
||||
Plan 06-05: all 8 xfail stubs promoted to real assertions.
|
||||
|
||||
D-11: trusted-proxy CIDR logic in get_client_ip (deps/utils.py replacement).
|
||||
D-11: trusted-proxy CIDR logic in get_client_ip (deps/utils.py).
|
||||
D-12: per-account rate limiter keyed by user_id (100 req/min).
|
||||
A1: slowapi key_func ordering assumption — request.state.current_user must be
|
||||
set as the FIRST line of the handler body before slowapi reads the key.
|
||||
|
||||
All tests use strict=False so an unexpected pass during development
|
||||
never breaks CI. Implementation lands in plan 06-04.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
# ── Helpers: build a minimal Starlette Request for unit tests ─────────────────
|
||||
|
||||
def _make_request(
|
||||
client_host: str | None,
|
||||
xff: str | None = None,
|
||||
) -> Request:
|
||||
"""Build a minimal starlette.requests.Request with the given peer IP and XFF header."""
|
||||
headers_list: list[tuple[bytes, bytes]] = []
|
||||
if xff is not None:
|
||||
headers_list.append((b"x-forwarded-for", xff.encode()))
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
"headers": headers_list,
|
||||
"client": (client_host, 12345) if client_host else None,
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
# ── D-11: trusted-proxy CIDR logic in get_client_ip ──────────────────────────
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_get_client_ip_untrusted_returns_direct_peer():
|
||||
def test_get_client_ip_untrusted_returns_direct_peer():
|
||||
"""When request.client.host is 8.8.8.8 (not in trusted CIDRs), ignore
|
||||
X-Forwarded-For and return the direct peer IP "8.8.8.8"."""
|
||||
pytest.xfail("not implemented yet")
|
||||
X-Forwarded-For and return the direct peer IP '8.8.8.8'."""
|
||||
from deps.utils import get_client_ip
|
||||
|
||||
req = _make_request("8.8.8.8", xff="1.2.3.4")
|
||||
assert get_client_ip(req) == "8.8.8.8"
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_get_client_ip_trusted_proxy_reads_xff_leftmost():
|
||||
def test_get_client_ip_trusted_proxy_reads_xff_leftmost():
|
||||
"""When request.client.host is 127.0.0.1 (trusted), return the leftmost
|
||||
address from X-Forwarded-For: "1.2.3.4, 5.6.7.8" → "1.2.3.4"."""
|
||||
pytest.xfail("not implemented yet")
|
||||
address from X-Forwarded-For: '1.2.3.4, 5.6.7.8' → '1.2.3.4'."""
|
||||
from deps.utils import get_client_ip
|
||||
|
||||
req = _make_request("127.0.0.1", xff="1.2.3.4, 5.6.7.8")
|
||||
assert get_client_ip(req) == "1.2.3.4"
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_get_client_ip_trusted_proxy_no_xff_falls_back():
|
||||
"""When the direct peer is trusted but no X-Forwarded-For header is present,
|
||||
return the direct peer IP as fallback."""
|
||||
pytest.xfail("not implemented yet")
|
||||
def test_get_client_ip_trusted_proxy_no_xff_falls_back():
|
||||
"""When the direct peer is trusted (172.16.5.5) but no X-Forwarded-For
|
||||
header is present, return the direct peer IP as fallback."""
|
||||
from deps.utils import get_client_ip
|
||||
|
||||
req = _make_request("172.16.5.5", xff=None)
|
||||
assert get_client_ip(req) == "172.16.5.5"
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_get_client_ip_invalid_peer_returns_none_or_string():
|
||||
def test_get_client_ip_invalid_peer_returns_none_or_string():
|
||||
"""When request.client is None, get_client_ip returns None without raising."""
|
||||
pytest.xfail("not implemented yet")
|
||||
from deps.utils import get_client_ip
|
||||
|
||||
req = _make_request(None, xff=None)
|
||||
result = get_client_ip(req)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── D-12: per-account rate limiter keyed by user_id ──────────────────────────
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_account_limiter_key_uses_user_id():
|
||||
def test_account_limiter_key_uses_user_id():
|
||||
"""_account_key(request) where request.state.current_user has id=UUID(...)
|
||||
returns str(user.id), not request.client.host."""
|
||||
pytest.xfail("not implemented yet")
|
||||
from services.rate_limiting import _account_key
|
||||
|
||||
req = _make_request("8.8.8.8")
|
||||
|
||||
class _FakeUser:
|
||||
id = uuid.uuid4()
|
||||
|
||||
req.state.current_user = _FakeUser()
|
||||
result = _account_key(req)
|
||||
assert result == str(_FakeUser.id)
|
||||
assert result != "8.8.8.8"
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_account_limiter_key_falls_back_to_ip_when_no_user():
|
||||
def test_account_limiter_key_falls_back_to_ip_when_no_user():
|
||||
"""When request.state.current_user is missing, the key function returns the
|
||||
direct peer IP — must not crash (Pitfall 3 guard)."""
|
||||
pytest.xfail("not implemented yet")
|
||||
from services.rate_limiting import _account_key
|
||||
|
||||
req = _make_request("9.9.9.9")
|
||||
# Do NOT set request.state.current_user
|
||||
result = _account_key(req)
|
||||
assert result == "9.9.9.9"
|
||||
|
||||
|
||||
# ── A1: key_func ordering assumption — unit verification ─────────────────────
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
async def test_account_limiter_key_ordering_assumption(async_client, auth_user):
|
||||
"""A1 verification: construct a FastAPI test endpoint that sets
|
||||
request.state.current_user as its first line, decorated with
|
||||
@account_limiter.limit("100/minute"); call it 101 times with the same user;
|
||||
assert the 101st response is 429 AND the limiter recorded the key as
|
||||
str(user.id), not the IP."""
|
||||
pytest.xfail("not implemented yet")
|
||||
def test_account_limiter_key_ordering_assumption():
|
||||
"""A1 verification: construct a FastAPI test app with one endpoint decorated
|
||||
@account_limiter.limit('100/minute') that sets request.state.current_user as
|
||||
its first line; call it 101 times with the same user; assert the 101st
|
||||
response is 429.
|
||||
|
||||
This validates that slowapi reads the key_func AFTER the first line of the
|
||||
handler has already set request.state.current_user (the ordering assumption
|
||||
that makes per-account limiting work).
|
||||
|
||||
Key correctness is verified separately via the returned body — the endpoint
|
||||
echoes back the key so we can confirm it is the user.id, not the peer IP.
|
||||
"""
|
||||
from services.rate_limiting import _account_key
|
||||
|
||||
_user_id = uuid.uuid4()
|
||||
|
||||
# Build an isolated limiter so this test never pollutes the shared instance
|
||||
from slowapi import Limiter
|
||||
_isolated_limiter = Limiter(key_func=_account_key)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.state.limiter = _isolated_limiter
|
||||
test_app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
test_app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
class _FakeUser:
|
||||
id = _user_id
|
||||
|
||||
@test_app.get("/limited")
|
||||
@_isolated_limiter.limit("100/minute")
|
||||
async def _limited_endpoint(request: Request):
|
||||
# FIRST line: set current_user so the key_func can read it
|
||||
request.state.current_user = _FakeUser()
|
||||
# Echo the key back so the test can assert it is the user.id
|
||||
key = _account_key(request)
|
||||
return {"ok": True, "key": key}
|
||||
|
||||
with TestClient(test_app, raise_server_exceptions=False) as client:
|
||||
responses = [client.get("/limited") for _ in range(101)]
|
||||
|
||||
status_codes = [r.status_code for r in responses]
|
||||
assert status_codes[-1] == 429, (
|
||||
f"Expected 429 on 101st request, got {status_codes[-1]}"
|
||||
)
|
||||
assert all(c == 200 for c in status_codes[:100]), (
|
||||
"First 100 requests should all be 200"
|
||||
)
|
||||
# Verify the key returned in the body is the user.id, not the IP
|
||||
first_body = responses[0].json()
|
||||
assert first_body.get("key") == str(_user_id), (
|
||||
f"Expected key '{_user_id}', got '{first_body.get('key')}'"
|
||||
)
|
||||
|
||||
|
||||
# ── D-12: full integration — 429 after 100 requests/minute ───────────────────
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implementation in 06-04")
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_endpoint_429_after_100_per_minute(
|
||||
async_client, auth_user
|
||||
):
|
||||
"""Full integration: GET /api/documents/ called 101 times with the same
|
||||
auth_user returns 429 on the 101st request."""
|
||||
pytest.xfail("not implemented yet")
|
||||
headers = auth_user["headers"]
|
||||
|
||||
responses = []
|
||||
for _ in range(101):
|
||||
r = await async_client.get("/api/documents", headers=headers)
|
||||
responses.append(r)
|
||||
|
||||
status_codes = [r.status_code for r in responses]
|
||||
assert status_codes[-1] == 429, (
|
||||
f"Expected 429 on 101st request, got {status_codes[-1]}"
|
||||
)
|
||||
assert all(c == 200 for c in status_codes[:100]), (
|
||||
f"First 100 should be 200, got: {[c for c in status_codes[:100] if c != 200]}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user