"""Tests for :class:`app.services.csrf.CSRFService`.""" from __future__ import annotations import pytest from itsdangerous import URLSafeTimedSerializer from app.services.csrf import CSRF_COOKIE_NAME, CSRFService @pytest.fixture def signer() -> URLSafeTimedSerializer: """Return an ``itsdangerous`` signer with the canonical CSRF salt.""" return URLSafeTimedSerializer("test-key-for-csrf-service-0123456789", salt="csrf") @pytest.fixture def service(signer: URLSafeTimedSerializer) -> CSRFService: """Return a dev-mode :class:`CSRFService` (Secure flag off).""" return CSRFService(signer, production=False) def test_issue_returns_matching_token_and_cookie(service: CSRFService) -> None: """Without a prior cookie, :meth:`issue` mints a fresh pair that verify. The token and the cookie value are the same signed string — by design — but the critical property is that ``verify`` accepts them as a match. """ token, cookie = service.issue(existing_cookie=None) assert token assert cookie assert service.verify(cookie_value=cookie, submitted=token) is True def test_issue_reuses_nonce_when_cookie_valid(service: CSRFService) -> None: """A still-valid cookie is reused so open admin tabs keep working.""" _, cookie = service.issue() token2, cookie2 = service.issue(existing_cookie=cookie) # Cookie value is deterministic post-nonce but itsdangerous signs # with a timestamp, so the signed strings differ even when the # underlying nonce is reused. The cross-pair verify matters. assert service.verify(cookie_value=cookie, submitted=token2) is True assert service.verify(cookie_value=cookie2, submitted=token2) is True def test_verify_rejects_tampered_token(service: CSRFService) -> None: """A single-char tweak to the signed token breaks the signature.""" token, cookie = service.issue() # Flip a char in the signature payload. tampered = token[:-1] + ("A" if token[-1] != "A" else "B") assert service.verify(cookie_value=cookie, submitted=tampered) is False def test_verify_rejects_different_cookie(service: CSRFService) -> None: """Two separately-issued nonces never match each other.""" t1, c1 = service.issue() t2, c2 = service.issue() assert service.verify(cookie_value=c1, submitted=t2) is False assert service.verify(cookie_value=c2, submitted=t1) is False def test_verify_rejects_missing_values(service: CSRFService) -> None: """Empty or None inputs fail closed.""" assert service.verify(cookie_value=None, submitted="x") is False assert service.verify(cookie_value="x", submitted=None) is False assert service.verify(cookie_value="", submitted="") is False def test_verify_rejects_different_key(signer: URLSafeTimedSerializer) -> None: """Tokens signed by a different key never verify.""" service_a = CSRFService(signer, production=False) other_signer = URLSafeTimedSerializer("DIFFERENT-KEY-XYZ", salt="csrf") service_b = CSRFService(other_signer, production=False) _, cookie_a = service_a.issue() token_b, _ = service_b.issue() assert service_a.verify(cookie_value=cookie_a, submitted=token_b) is False def test_cookie_params_production_sets_secure(signer: URLSafeTimedSerializer) -> None: """``Secure=True`` only when constructed with ``production=True``.""" dev = CSRFService(signer, production=False) prod = CSRFService(signer, production=True) assert dev.cookie_params()["secure"] is False assert prod.cookie_params()["secure"] is True assert dev.cookie_params()["key"] == CSRF_COOKIE_NAME # HttpOnly is intentionally off so JS can read the cookie for # double-submit header POSTs. assert dev.cookie_params()["httponly"] is False assert dev.cookie_params()["samesite"] == "lax"