Files
gh-christianlouis-docuelevate/tests/test_csrf.py
2026-03-18 22:25:27 +00:00

411 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the CSRF protection middleware (app/middleware/csrf.py)."""
import secrets
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
from starlette.responses import JSONResponse, RedirectResponse
from app.middleware.csrf import CSRF_EXEMPT_PATHS, CSRF_PROTECTED_METHODS, CSRFMiddleware
# ---------------------------------------------------------------------------
# Unit tests CSRFMiddleware._get_submitted_token
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetSubmittedToken:
"""Unit tests for the CSRF token extraction helper."""
@pytest.mark.asyncio
async def test_returns_header_token(self):
"""Token is read from the X-CSRF-Token request header."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"X-CSRF-Token": "abc123"}
mock_request.form = AsyncMock(return_value={})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token == "abc123"
@pytest.mark.asyncio
async def test_returns_form_token_for_urlencoded(self):
"""Token is read from the form body for URL-encoded POST data."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
mock_request.body = AsyncMock(return_value=b"csrf_token=form_token_xyz")
mock_request.form = AsyncMock(return_value={"csrf_token": "form_token_xyz"})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token == "form_token_xyz"
# body() must have been awaited so the body is cached for downstream re-reads.
mock_request.body.assert_awaited_once()
@pytest.mark.asyncio
async def test_header_takes_priority_over_form(self):
"""Header token takes priority over form body token."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {
"X-CSRF-Token": "header_token",
"content-type": "application/x-www-form-urlencoded",
}
mock_request.form = AsyncMock(return_value={"csrf_token": "form_token"})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token == "header_token"
@pytest.mark.asyncio
async def test_returns_none_when_no_token(self):
"""Returns None when no token is present in header or body."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "application/json"}
mock_request.form = AsyncMock(return_value={})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token is None
@pytest.mark.asyncio
async def test_returns_none_for_multipart_without_header(self):
"""Multipart bodies without a header should return None (not parsed)."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "multipart/form-data; boundary=----boundary"}
mock_request.form = AsyncMock(return_value={"csrf_token": "should_not_be_read"})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token is None
@pytest.mark.asyncio
async def test_body_is_cached_before_form_parse(self):
"""body() is called before form() so downstream handlers can re-read the body.
This covers the Starlette BaseHTTPMiddleware body-replay bug: if form()
is called without first calling body(), _stream_consumed is set to True
but _body remains unset. _CachedRequest.wrapped_receive then forwards
an empty body to downstream apps (e.g. the /auth endpoint) causing
form_keys=[] and login failures. Calling body() first caches _body so
wrapped_receive correctly replays the full body.
"""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
call_order: list[str] = []
async def _body():
call_order.append("body")
return b"csrf_token=tok&username=alice&password=test_password"
async def _form():
call_order.append("form")
return {"csrf_token": "tok", "username": "alice", "password": "test_password"}
mock_request.body = _body
mock_request.form = _form
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token == "tok"
# body() must be called BEFORE form() to ensure body caching.
assert call_order == ["body", "form"]
@pytest.mark.asyncio
async def test_body_is_not_called_when_header_present(self):
"""When the CSRF token is in the X-CSRF-Token header, body() is not called.
For header-based token submission (AJAX / fetch requests) we skip body
parsing entirely, so the body stream remains unconsumed and downstream
handlers can read it normally.
"""
mock_request = MagicMock(spec=Request)
mock_request.headers = {
"X-CSRF-Token": "header_tok",
"content-type": "application/x-www-form-urlencoded",
}
mock_request.body = AsyncMock(return_value=b"username=alice")
mock_request.form = AsyncMock(return_value={"csrf_token": "header_tok"})
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token == "header_tok"
# body() must NOT be called the header path returns early.
mock_request.body.assert_not_awaited()
@pytest.mark.asyncio
async def test_handles_form_parse_exception_gracefully(self):
"""A broken form body does not crash the middleware."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {"content-type": "application/x-www-form-urlencoded"}
mock_request.body = AsyncMock(return_value=b"")
mock_request.form = AsyncMock(side_effect=Exception("parse error"))
token = await CSRFMiddleware._get_submitted_token(mock_request)
assert token is None
# Unit tests CSRFMiddleware.dispatch
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCSRFMiddlewareDispatch:
"""Unit tests for the CSRFMiddleware.dispatch method."""
def _make_middleware(self, auth_enabled: bool = True):
mock_app = AsyncMock()
mock_config = MagicMock()
mock_config.auth_enabled = auth_enabled
return CSRFMiddleware(mock_app, mock_config)
def _make_request(self, method="GET", path="/", session=None, headers=None, state=None):
mock_request = MagicMock(spec=Request)
mock_request.method = method
mock_request.url = MagicMock()
mock_request.url.path = path
mock_request.session = session if session is not None else {}
mock_request.headers = headers or {}
mock_request.state = MagicMock()
mock_request.state.csrf_token = None
return mock_request
@pytest.mark.asyncio
async def test_noop_when_auth_disabled(self):
"""Middleware is a no-op when AUTH_ENABLED is False."""
middleware = self._make_middleware(auth_enabled=False)
request = self._make_request(method="POST", path="/api/test")
next_response = MagicMock()
call_next = AsyncMock(return_value=next_response)
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
assert result is next_response
@pytest.mark.asyncio
async def test_generates_token_when_not_in_session(self):
"""A new CSRF token is generated and stored in the session when absent."""
middleware = self._make_middleware()
request = self._make_request(method="GET", session={})
call_next = AsyncMock(return_value=MagicMock())
await middleware.dispatch(request, call_next)
assert "csrf_token" in request.session
token = request.session["csrf_token"]
assert len(token) == 64 # secrets.token_hex(32) -> 64 hex chars
@pytest.mark.asyncio
async def test_reuses_existing_token_from_session(self):
"""An existing session token is reused instead of regenerating."""
existing_token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(method="GET", session={"csrf_token": existing_token})
call_next = AsyncMock(return_value=MagicMock())
await middleware.dispatch(request, call_next)
assert request.session["csrf_token"] == existing_token
assert request.state.csrf_token == existing_token
@pytest.mark.asyncio
async def test_attaches_token_to_request_state(self):
"""Token is always attached to request.state.csrf_token."""
middleware = self._make_middleware()
request = self._make_request(method="GET", session={})
call_next = AsyncMock(return_value=MagicMock())
await middleware.dispatch(request, call_next)
assert request.state.csrf_token is not None
assert len(request.state.csrf_token) == 64
@pytest.mark.asyncio
async def test_safe_methods_pass_without_token(self):
"""GET/HEAD/OPTIONS requests pass through without CSRF validation."""
middleware = self._make_middleware()
for method in ("GET", "HEAD", "OPTIONS"):
request = self._make_request(method=method, session={})
call_next = AsyncMock(return_value=MagicMock())
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
call_next.reset_mock()
@pytest.mark.asyncio
async def test_post_with_valid_header_token_passes(self):
"""POST with a matching X-CSRF-Token header passes validation."""
token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/process/",
session={"csrf_token": token},
headers={"X-CSRF-Token": token},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=token)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_post_with_invalid_token_returns_403_for_api(self):
"""POST with a wrong token on an API route returns HTTP 403."""
token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/process/",
session={"csrf_token": token},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value="wrong_token")):
result = await middleware.dispatch(request, call_next)
assert isinstance(result, JSONResponse)
assert result.status_code == 403
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_post_with_missing_token_returns_403_for_api(self):
"""POST with no CSRF token on an API route returns HTTP 403."""
token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/settings/bulk-update",
session={"csrf_token": token},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
assert isinstance(result, JSONResponse)
assert result.status_code == 403
@pytest.mark.asyncio
async def test_post_with_invalid_token_redirects_for_frontend(self):
"""POST with a wrong token on a frontend route redirects to /login."""
token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/auth",
session={"csrf_token": token},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value="bad_token")):
result = await middleware.dispatch(request, call_next)
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert "/login?error=Invalid+request" in result.headers["location"]
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_delete_with_valid_token_passes(self):
"""DELETE with a matching token passes through."""
token = secrets.token_hex(32)
middleware = self._make_middleware()
request = self._make_request(
method="DELETE",
path="/api/files/1",
session={"csrf_token": token},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=token)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_oauth_callback_is_exempt(self):
"""OAuth callback path is exempt from CSRF validation even on POST."""
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/oauth-callback",
session={"csrf_token": secrets.token_hex(32)},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_qr_auth_claim_is_exempt(self):
"""QR auth claim path is exempt from CSRF validation.
The mobile app calls this endpoint without a browser session and
therefore without a CSRF token. The cryptographically-random,
single-use challenge token provides equivalent protection.
"""
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/qr-auth/claim",
session={},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# ---------------------------------------------------------------------------
# Integration tests via TestClient
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.security
class TestCSRFIntegration:
"""Integration tests for CSRF protection using the FastAPI TestClient.
The shared ``client`` fixture runs with ``AUTH_ENABLED=False`` (see
``conftest.py``), so the CSRF middleware is disabled by design. The tests
below verify constants and confirm that the middleware is a no-op in that
configuration.
"""
def test_csrf_constants(self):
"""Verify the constant sets have the expected members."""
assert "POST" in CSRF_PROTECTED_METHODS
assert "PUT" in CSRF_PROTECTED_METHODS
assert "DELETE" in CSRF_PROTECTED_METHODS
assert "PATCH" in CSRF_PROTECTED_METHODS
assert "GET" not in CSRF_PROTECTED_METHODS
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
def test_csrf_middleware_noop_when_auth_disabled(self):
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
# Build a middleware instance with auth disabled.
mock_app = AsyncMock()
mock_config = MagicMock()
mock_config.auth_enabled = False
middleware = CSRFMiddleware(mock_app, mock_config)
import asyncio
mock_request = MagicMock(spec=Request)
mock_request.method = "POST"
mock_request.url = MagicMock()
mock_request.url.path = "/api/process/"
mock_request.session = {}
call_next = AsyncMock(return_value=MagicMock())
asyncio.run(middleware.dispatch(mock_request, call_next))
# call_next must have been called (request was not blocked).
call_next.assert_called_once_with(mock_request)
# Session should remain untouched (no token generated).
assert "csrf_token" not in mock_request.session