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:
2026-01-05 15:20:26 +00:00
parent a4b4fe6ab8
commit 14bf2fa4ae
16 changed files with 405 additions and 64 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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: