204 lines
6.9 KiB
Python
204 lines
6.9 KiB
Python
import re
|
|
from collections.abc import AsyncIterator
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
from fastapi.testclient import TestClient
|
|
import pytest
|
|
|
|
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
|
from app.auth.rate_limit import SlidingWindowRateLimiter
|
|
from app.db.session import get_db_session
|
|
from app.main import app
|
|
|
|
CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
|
|
|
|
|
class StubAuthService:
|
|
def __init__(self, *, user: object | None) -> None:
|
|
self._user = user
|
|
|
|
async def authenticate_user(self, _db_session, *, email: str, password: str):
|
|
if self._user is None:
|
|
return None
|
|
if email.strip().lower() != str(self._user.email):
|
|
return None
|
|
if password != "correct-password":
|
|
return None
|
|
return self._user
|
|
|
|
|
|
def _extract_csrf_token(html: str) -> str:
|
|
match = CSRF_TOKEN_PATTERN.search(html)
|
|
assert match is not None
|
|
return match.group(1)
|
|
|
|
|
|
async def _override_db_session() -> AsyncIterator[object]:
|
|
yield object()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_dependency_overrides() -> AsyncIterator[None]:
|
|
app.dependency_overrides.clear()
|
|
yield
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_login_requires_csrf_token() -> None:
|
|
client = TestClient(app)
|
|
|
|
response = client.post(
|
|
"/login",
|
|
data={"email": "user@example.com", "password": "correct-password"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.text == "CSRF token missing."
|
|
|
|
|
|
def test_successful_login_creates_session_and_allows_dashboard(monkeypatch) -> None:
|
|
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
|
app.dependency_overrides[get_db_session] = _override_db_session
|
|
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
|
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
|
)
|
|
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
|
client = TestClient(app)
|
|
monkeypatch.setattr(
|
|
"app.web.common.get_authenticated_user",
|
|
AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
id=1,
|
|
email="user@example.com",
|
|
is_admin=False,
|
|
)
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
|
AsyncMock(return_value=[]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
|
AsyncMock(return_value=[]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
|
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
|
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
|
)
|
|
|
|
login_page = client.get("/login")
|
|
csrf_token = _extract_csrf_token(login_page.text)
|
|
|
|
login_response = client.post(
|
|
"/login",
|
|
data={
|
|
"email": "user@example.com",
|
|
"password": "correct-password",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert login_response.status_code == 303
|
|
assert login_response.headers["location"] == "/"
|
|
|
|
dashboard_response = client.get("/")
|
|
assert dashboard_response.status_code == 200
|
|
assert 'data-test="home-hero"' in dashboard_response.text
|
|
assert 'data-test="session-user"' in dashboard_response.text
|
|
assert "user@example.com" in dashboard_response.text
|
|
|
|
|
|
def test_login_rate_limiting_returns_429_after_threshold() -> None:
|
|
limiter = SlidingWindowRateLimiter(max_attempts=2, window_seconds=60)
|
|
app.dependency_overrides[get_db_session] = _override_db_session
|
|
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(user=None)
|
|
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
|
client = TestClient(app)
|
|
|
|
login_page = client.get("/login")
|
|
csrf_token = _extract_csrf_token(login_page.text)
|
|
payload = {
|
|
"email": "user@example.com",
|
|
"password": "wrong-password",
|
|
"csrf_token": csrf_token,
|
|
}
|
|
|
|
first = client.post("/login", data=payload, follow_redirects=False)
|
|
second = client.post("/login", data=payload, follow_redirects=False)
|
|
third = client.post("/login", data=payload, follow_redirects=False)
|
|
|
|
assert first.status_code == 401
|
|
assert second.status_code == 401
|
|
assert third.status_code == 429
|
|
assert third.headers["Retry-After"] == "60"
|
|
|
|
|
|
def test_logout_requires_csrf_token_and_clears_session(monkeypatch) -> None:
|
|
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
|
app.dependency_overrides[get_db_session] = _override_db_session
|
|
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
|
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
|
)
|
|
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
|
client = TestClient(app)
|
|
monkeypatch.setattr(
|
|
"app.web.common.get_authenticated_user",
|
|
AsyncMock(
|
|
side_effect=[
|
|
SimpleNamespace(id=1, email="user@example.com", is_admin=False),
|
|
None,
|
|
]
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
|
AsyncMock(return_value=[]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
|
AsyncMock(return_value=[]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
|
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
|
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
|
)
|
|
|
|
login_page = client.get("/login")
|
|
csrf_token = _extract_csrf_token(login_page.text)
|
|
client.post(
|
|
"/login",
|
|
data={
|
|
"email": "user@example.com",
|
|
"password": "correct-password",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
failed_logout = client.post("/logout", data={}, follow_redirects=False)
|
|
assert failed_logout.status_code == 403
|
|
assert failed_logout.text == "CSRF token invalid."
|
|
|
|
dashboard_page = client.get("/")
|
|
logout_token = _extract_csrf_token(dashboard_page.text)
|
|
successful_logout = client.post(
|
|
"/logout",
|
|
data={"csrf_token": logout_token},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert successful_logout.status_code == 303
|
|
assert successful_logout.headers["location"] == "/login"
|
|
assert client.get("/", follow_redirects=False).headers["location"] == "/login"
|