- Fix CSRF token handling for production: generate tokens with HMAC, set cookie via afterware, inject into HTMX requests via JS - Improve registry page: filter at top with better proportions, compact horizontal pill layout for facets - Add phonetic ID encoding (e.g., "tobi-kafu-meli") for animal display instead of truncated ULIDs - Remove "subadult" life stage (migration converts to juvenile) - Change "Death (natural)" outcome label to just "Death" - Show sex/life stage in animal picker alongside species/location 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
# ABOUTME: Tests for CSRF validation logic.
|
|
# ABOUTME: Covers token generation, token matching, origin/referer validation, and safe methods.
|
|
|
|
|
|
class TestGenerateCSRFToken:
|
|
"""Tests for CSRF token generation."""
|
|
|
|
def test_generates_token_with_nonce_and_signature(self):
|
|
"""Token format is nonce:signature."""
|
|
from animaltrack.web.middleware import generate_csrf_token
|
|
|
|
token = generate_csrf_token("test-secret")
|
|
parts = token.split(":")
|
|
assert len(parts) == 2
|
|
# Nonce is 32 hex chars (16 bytes)
|
|
assert len(parts[0]) == 32
|
|
# Signature is 64 hex chars (SHA256)
|
|
assert len(parts[1]) == 64
|
|
|
|
def test_generates_unique_tokens(self):
|
|
"""Each call generates a different token (random nonce)."""
|
|
from animaltrack.web.middleware import generate_csrf_token
|
|
|
|
token1 = generate_csrf_token("test-secret")
|
|
token2 = generate_csrf_token("test-secret")
|
|
assert token1 != token2
|
|
|
|
def test_tokens_are_hex_encoded(self):
|
|
"""Token parts are valid hex strings."""
|
|
from animaltrack.web.middleware import generate_csrf_token
|
|
|
|
token = generate_csrf_token("test-secret")
|
|
nonce, signature = token.split(":")
|
|
# These should not raise ValueError
|
|
int(nonce, 16)
|
|
int(signature, 16)
|
|
|
|
|
|
class TestValidateCSRFToken:
|
|
"""Tests for CSRF token validation logic."""
|
|
|
|
def test_accepts_matching_tokens(self):
|
|
"""Valid when cookie and header tokens match."""
|
|
from animaltrack.web.middleware import validate_csrf_token
|
|
|
|
assert validate_csrf_token("abc123", "abc123") is True
|
|
|
|
def test_rejects_mismatched_tokens(self):
|
|
"""Invalid when cookie and header tokens differ."""
|
|
from animaltrack.web.middleware import validate_csrf_token
|
|
|
|
assert validate_csrf_token("abc123", "xyz789") is False
|
|
|
|
def test_rejects_empty_cookie_token(self):
|
|
"""Invalid when cookie token is empty."""
|
|
from animaltrack.web.middleware import validate_csrf_token
|
|
|
|
assert validate_csrf_token("", "abc123") is False
|
|
|
|
def test_rejects_empty_header_token(self):
|
|
"""Invalid when header token is empty."""
|
|
from animaltrack.web.middleware import validate_csrf_token
|
|
|
|
assert validate_csrf_token("abc123", "") is False
|
|
|
|
def test_rejects_none_tokens(self):
|
|
"""Invalid when either token is None."""
|
|
from animaltrack.web.middleware import validate_csrf_token
|
|
|
|
assert validate_csrf_token(None, "abc123") is False
|
|
assert validate_csrf_token("abc123", None) is False
|
|
assert validate_csrf_token(None, None) is False
|
|
|
|
|
|
class TestIsSafeMethod:
|
|
"""Tests for HTTP safe method detection."""
|
|
|
|
def test_get_is_safe(self):
|
|
"""GET is a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("GET") is True
|
|
|
|
def test_head_is_safe(self):
|
|
"""HEAD is a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("HEAD") is True
|
|
|
|
def test_options_is_safe(self):
|
|
"""OPTIONS is a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("OPTIONS") is True
|
|
|
|
def test_post_is_not_safe(self):
|
|
"""POST is not a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("POST") is False
|
|
|
|
def test_put_is_not_safe(self):
|
|
"""PUT is not a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("PUT") is False
|
|
|
|
def test_delete_is_not_safe(self):
|
|
"""DELETE is not a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("DELETE") is False
|
|
|
|
def test_patch_is_not_safe(self):
|
|
"""PATCH is not a safe method."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("PATCH") is False
|
|
|
|
def test_case_insensitive(self):
|
|
"""Method check is case-insensitive."""
|
|
from animaltrack.web.middleware import is_safe_method
|
|
|
|
assert is_safe_method("get") is True
|
|
assert is_safe_method("Get") is True
|
|
assert is_safe_method("post") is False
|
|
|
|
|
|
class TestValidateOrigin:
|
|
"""Tests for Origin/Referer header validation."""
|
|
|
|
def test_accepts_matching_origin(self):
|
|
"""Valid when Origin matches expected host."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin("https://example.com", "example.com") is True
|
|
|
|
def test_accepts_matching_origin_with_port(self):
|
|
"""Valid when Origin matches expected host with port."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin("https://example.com:3366", "example.com:3366") is True
|
|
|
|
def test_rejects_different_origin(self):
|
|
"""Invalid when Origin doesn't match expected host."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin("https://evil.com", "example.com") is False
|
|
|
|
def test_rejects_subdomain_mismatch(self):
|
|
"""Invalid when Origin is a subdomain of expected host."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin("https://sub.example.com", "example.com") is False
|
|
|
|
def test_accepts_none_origin(self):
|
|
"""None origin returns False (will check Referer instead)."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin(None, "example.com") is False
|
|
|
|
def test_accepts_empty_origin(self):
|
|
"""Empty origin returns False."""
|
|
from animaltrack.web.middleware import validate_origin
|
|
|
|
assert validate_origin("", "example.com") is False
|
|
|
|
|
|
class TestValidateReferer:
|
|
"""Tests for Referer header validation."""
|
|
|
|
def test_accepts_matching_referer(self):
|
|
"""Valid when Referer host matches expected host."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer("https://example.com/page", "example.com") is True
|
|
|
|
def test_accepts_matching_referer_with_port(self):
|
|
"""Valid when Referer matches expected host with port."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer("https://example.com:3366/page", "example.com:3366") is True
|
|
|
|
def test_rejects_different_referer(self):
|
|
"""Invalid when Referer host doesn't match expected host."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer("https://evil.com/page", "example.com") is False
|
|
|
|
def test_rejects_none_referer(self):
|
|
"""Invalid when Referer is None."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer(None, "example.com") is False
|
|
|
|
def test_rejects_empty_referer(self):
|
|
"""Invalid when Referer is empty."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer("", "example.com") is False
|
|
|
|
def test_rejects_malformed_referer(self):
|
|
"""Invalid when Referer is malformed."""
|
|
from animaltrack.web.middleware import validate_referer
|
|
|
|
assert validate_referer("not-a-url", "example.com") is False
|