First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
204
tests/unit/test_auth.py
Normal file
204
tests/unit/test_auth.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue