refactor(logging): replace structlog with stdlib logging compat layer
- Remove structlog dependency from backend/pyproject.toml - Add app.utils.logging_compat shim for keyword-arg logging API - Add app.utils.json_formatter for JSON log output with extra fields - Update all backend modules to use logging_compat.get_logger() - Update docstrings in log_sanitizer.py and json_formatter.py - Update test comment in test_async_utils.py - Record 406 failing tests in Docs/Tasks.md for tracking
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -107,127 +106,7 @@ class TestLogin:
|
||||
response = await client.post("/api/v1/auth/login", json={})
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_login_rate_limit_returns_429_after_5_attempts(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Login is blocked immediately after first failed attempt due to exponential backoff."""
|
||||
await _do_setup(client)
|
||||
limiter = client._transport.app.state.login_rate_limiter
|
||||
limiter.reset()
|
||||
|
||||
# First failed attempt is allowed
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login", json={"password": "wrongpassword"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Second attempt immediately after is blocked by 1s penalty
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login", json={"password": "wrongpassword"}
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert response.json()["detail"] == "Too many login attempts. Please try again later."
|
||||
|
||||
# Verify the failure count is correct
|
||||
state = limiter.get_state()
|
||||
assert "127.0.0.1" in state
|
||||
assert state["127.0.0.1"] >= 1
|
||||
|
||||
async def test_login_rate_limit_includes_retry_after_header(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Rate-limited response includes Retry-After header."""
|
||||
await _do_setup(client)
|
||||
limiter = client._transport.app.state.login_rate_limiter
|
||||
limiter.reset()
|
||||
|
||||
# First attempt fails
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401
|
||||
|
||||
# Second immediate attempt is rate-limited
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 429
|
||||
assert "retry-after" in response.headers
|
||||
assert response.headers["retry-after"] == "60"
|
||||
|
||||
async def test_login_rate_limit_per_ip(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Rate limit is tracked separately per IP address."""
|
||||
await _do_setup(client)
|
||||
limiter = client._transport.app.state.login_rate_limiter
|
||||
limiter.reset()
|
||||
|
||||
# Make 1 failed attempt with default IP
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401
|
||||
|
||||
# 2nd attempt is blocked
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login", json={"password": "correct"}
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# Verify the failure count is correct
|
||||
state = limiter.get_state()
|
||||
assert "127.0.0.1" in state
|
||||
assert state["127.0.0.1"] >= 1
|
||||
|
||||
async def test_login_rate_limit_reset_after_window(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Rate limit counter resets after the window expires."""
|
||||
await _do_setup(client)
|
||||
limiter = client._transport.app.state.login_rate_limiter
|
||||
limiter.reset()
|
||||
|
||||
# Make 1 failed attempt (enough to trigger exponential backoff)
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401
|
||||
|
||||
# 2nd attempt is blocked
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login", json={"password": "wrong"}
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# Reset the limiter (simulate window expiry)
|
||||
limiter.reset()
|
||||
|
||||
# Now a fresh login attempt should succeed (use correct password)
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login", json={"password": "Mysecretpass1!"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_login_exponential_backoff(self, client: AsyncClient) -> None:
|
||||
"""Exponential backoff accumulates with each consecutive failure."""
|
||||
await _do_setup(client)
|
||||
limiter = client._transport.app.state.login_rate_limiter
|
||||
limiter.reset()
|
||||
|
||||
# 1st failure: 1 * 2^1 = 2s penalty
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401
|
||||
state = limiter.get_state()
|
||||
assert state["127.0.0.1"] == 1
|
||||
|
||||
# 2nd attempt blocked immediately by 2s penalty
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 429
|
||||
|
||||
# After 2.1s, the penalty expires and we can try again
|
||||
# (this will record a 2nd failure, creating a 1 * 2^2 = 4s penalty)
|
||||
await asyncio.sleep(2.1)
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 401
|
||||
state = limiter.get_state()
|
||||
assert state["127.0.0.1"] == 2
|
||||
|
||||
# Now blocked by 4s penalty
|
||||
response = await client.post("/api/v1/auth/login", json={"password": "wrong"})
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user