Fix CSRF 403, improve registry UI, add phonetic IDs
- 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>
This commit is contained in:
@@ -81,7 +81,6 @@ class TestEnums:
|
||||
"""LifeStage enum has correct values."""
|
||||
assert LifeStage.HATCHLING.value == "hatchling"
|
||||
assert LifeStage.JUVENILE.value == "juvenile"
|
||||
assert LifeStage.SUBADULT.value == "subadult"
|
||||
assert LifeStage.ADULT.value == "adult"
|
||||
|
||||
def test_animal_status_values(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ABOUTME: Tests for ULID generation utility.
|
||||
# ABOUTME: Verifies that generated IDs are valid 26-character ULIDs and unique.
|
||||
# ABOUTME: Tests for ULID generation and phonetic encoding utilities.
|
||||
# ABOUTME: Verifies ULID format, uniqueness, and phonetic display encoding.
|
||||
|
||||
from animaltrack.id_gen import generate_id
|
||||
from animaltrack.id_gen import format_animal_id, generate_id, ulid_to_phonetic
|
||||
|
||||
|
||||
class TestGenerateId:
|
||||
@@ -28,3 +28,74 @@ class TestGenerateId:
|
||||
# Crockford base32 excludes I, L, O, U
|
||||
valid_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
||||
assert all(c in valid_chars for c in result)
|
||||
|
||||
|
||||
class TestUlidToPhonetic:
|
||||
"""Tests for the ulid_to_phonetic function."""
|
||||
|
||||
def test_returns_hyphenated_syllables(self):
|
||||
"""Result is hyphen-separated syllables."""
|
||||
ulid = generate_id()
|
||||
result = ulid_to_phonetic(ulid)
|
||||
parts = result.split("-")
|
||||
assert len(parts) == 4 # Default syllable count
|
||||
|
||||
def test_syllables_are_cv_format(self):
|
||||
"""Each syllable is consonant-vowel format."""
|
||||
ulid = generate_id()
|
||||
result = ulid_to_phonetic(ulid)
|
||||
consonants = set("bdfgklmnprstvwxz")
|
||||
vowels = set("aeiou")
|
||||
for syllable in result.split("-"):
|
||||
assert len(syllable) == 2
|
||||
assert syllable[0] in consonants
|
||||
assert syllable[1] in vowels
|
||||
|
||||
def test_same_ulid_produces_same_phonetic(self):
|
||||
"""Same ULID always produces the same phonetic."""
|
||||
ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
result1 = ulid_to_phonetic(ulid)
|
||||
result2 = ulid_to_phonetic(ulid)
|
||||
assert result1 == result2
|
||||
|
||||
def test_different_ulids_produce_different_phonetics(self):
|
||||
"""Different ULIDs produce different phonetics (with high probability)."""
|
||||
ulids = [generate_id() for _ in range(100)]
|
||||
phonetics = [ulid_to_phonetic(u) for u in ulids]
|
||||
# All should be unique
|
||||
assert len(set(phonetics)) == 100
|
||||
|
||||
def test_custom_syllable_count(self):
|
||||
"""Can specify custom number of syllables."""
|
||||
ulid = generate_id()
|
||||
result = ulid_to_phonetic(ulid, syllable_count=3)
|
||||
assert len(result.split("-")) == 3
|
||||
|
||||
result = ulid_to_phonetic(ulid, syllable_count=5)
|
||||
assert len(result.split("-")) == 5
|
||||
|
||||
|
||||
class TestFormatAnimalId:
|
||||
"""Tests for the format_animal_id function."""
|
||||
|
||||
def test_returns_nickname_when_provided(self):
|
||||
"""Returns nickname if provided."""
|
||||
ulid = generate_id()
|
||||
result = format_animal_id(ulid, nickname="Daisy")
|
||||
assert result == "Daisy"
|
||||
|
||||
def test_returns_phonetic_when_no_nickname(self):
|
||||
"""Returns phonetic encoding when nickname is None."""
|
||||
ulid = generate_id()
|
||||
result = format_animal_id(ulid, nickname=None)
|
||||
# Should be phonetic format (4 syllables, hyphen-separated)
|
||||
parts = result.split("-")
|
||||
assert len(parts) == 4
|
||||
|
||||
def test_returns_phonetic_when_nickname_empty(self):
|
||||
"""Returns phonetic encoding when nickname is empty string."""
|
||||
ulid = generate_id()
|
||||
# Empty string is falsy, should use phonetic
|
||||
result = format_animal_id(ulid, nickname="")
|
||||
parts = result.split("-")
|
||||
assert len(parts) == 4
|
||||
|
||||
@@ -141,7 +141,7 @@ class TestAnimalRegistryTable:
|
||||
)
|
||||
|
||||
def test_life_stage_check_constraint(self, migrated_db):
|
||||
"""Life stage must be hatchling, juvenile, subadult, or adult."""
|
||||
"""Life stage must be hatchling, juvenile, or adult."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# ABOUTME: Tests for CSRF validation logic.
|
||||
# ABOUTME: Covers token matching, origin/referer validation, and safe methods.
|
||||
# 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:
|
||||
|
||||
Reference in New Issue
Block a user