diff --git a/migrations/0011-remove-subadult-lifestage.sql b/migrations/0011-remove-subadult-lifestage.sql new file mode 100644 index 0000000..be41910 --- /dev/null +++ b/migrations/0011-remove-subadult-lifestage.sql @@ -0,0 +1,70 @@ +-- ABOUTME: Removes subadult life stage, migrating existing records to juvenile. +-- ABOUTME: Updates CHECK constraints on animal_registry and live_animals_by_location. + +-- Update existing subadult animals to juvenile in animal_registry +UPDATE animal_registry SET life_stage = 'juvenile' WHERE life_stage = 'subadult'; + +-- Update existing subadult animals in live_animals_by_location +UPDATE live_animals_by_location SET life_stage = 'juvenile' WHERE life_stage = 'subadult'; + +-- SQLite doesn't support ALTER TABLE to modify CHECK constraints +-- We need to recreate the tables with the updated constraint + +-- Step 1: Recreate animal_registry table +CREATE TABLE animal_registry_new ( + animal_id TEXT PRIMARY KEY CHECK(length(animal_id) = 26), + species_code TEXT NOT NULL REFERENCES species(code), + identified INTEGER NOT NULL DEFAULT 0 CHECK(identified IN (0, 1)), + nickname TEXT, + sex TEXT NOT NULL CHECK(sex IN ('male', 'female', 'unknown')), + repro_status TEXT NOT NULL CHECK(repro_status IN ('intact', 'wether', 'spayed', 'unknown')), + life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling', 'juvenile', 'adult')), + status TEXT NOT NULL CHECK(status IN ('alive', 'dead', 'harvested', 'sold', 'merged_into')), + location_id TEXT NOT NULL REFERENCES locations(id), + origin TEXT NOT NULL CHECK(origin IN ('hatched', 'purchased', 'rescued', 'unknown')), + born_or_hatched_at INTEGER, + acquired_at INTEGER, + first_seen_utc INTEGER NOT NULL, + last_event_utc INTEGER NOT NULL +); + +INSERT INTO animal_registry_new SELECT * FROM animal_registry; + +DROP TABLE animal_registry; + +ALTER TABLE animal_registry_new RENAME TO animal_registry; + +-- Recreate indexes for animal_registry +CREATE UNIQUE INDEX idx_ar_nickname_active + ON animal_registry(nickname) + WHERE nickname IS NOT NULL + AND status NOT IN ('dead', 'harvested', 'sold', 'merged_into'); +CREATE INDEX idx_ar_location ON animal_registry(location_id); +CREATE INDEX idx_ar_filter ON animal_registry(species_code, sex, life_stage, identified); +CREATE INDEX idx_ar_status ON animal_registry(status); +CREATE INDEX idx_ar_last_event ON animal_registry(last_event_utc); + +-- Step 2: Recreate live_animals_by_location table +CREATE TABLE live_animals_by_location_new ( + animal_id TEXT PRIMARY KEY CHECK(length(animal_id) = 26), + location_id TEXT NOT NULL REFERENCES locations(id), + species_code TEXT NOT NULL REFERENCES species(code), + identified INTEGER NOT NULL DEFAULT 0 CHECK(identified IN (0, 1)), + nickname TEXT, + sex TEXT NOT NULL CHECK(sex IN ('male', 'female', 'unknown')), + repro_status TEXT NOT NULL CHECK(repro_status IN ('intact', 'wether', 'spayed', 'unknown')), + life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling', 'juvenile', 'adult')), + first_seen_utc INTEGER NOT NULL, + last_move_utc INTEGER, + tags TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(tags)) +); + +INSERT INTO live_animals_by_location_new SELECT * FROM live_animals_by_location; + +DROP TABLE live_animals_by_location; + +ALTER TABLE live_animals_by_location_new RENAME TO live_animals_by_location; + +-- Recreate indexes for live_animals_by_location +CREATE INDEX idx_labl_location ON live_animals_by_location(location_id); +CREATE INDEX idx_labl_filter ON live_animals_by_location(location_id, species_code, sex, life_stage, identified); \ No newline at end of file diff --git a/spec.md b/spec.md index b1c7635..95fe0fa 100644 --- a/spec.md +++ b/spec.md @@ -168,7 +168,7 @@ CREATE TABLE animal_registry ( nickname TEXT, sex TEXT NOT NULL CHECK(sex IN ('male','female','unknown')), repro_status TEXT NOT NULL CHECK(repro_status IN ('intact','wether','spayed','unknown')), - life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','subadult','adult')), + life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','adult')), status TEXT NOT NULL CHECK(status IN ('alive','dead','harvested','sold','merged_into')), location_id TEXT NOT NULL REFERENCES locations(id), origin TEXT NOT NULL CHECK(origin IN ('hatched','purchased','rescued','unknown')), @@ -193,7 +193,7 @@ CREATE TABLE live_animals_by_location ( nickname TEXT, sex TEXT NOT NULL CHECK(sex IN ('male','female','unknown')), repro_status TEXT NOT NULL CHECK(repro_status IN ('intact','wether','spayed','unknown')), - life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','subadult','adult')), + life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','adult')), first_seen_utc INTEGER NOT NULL, last_move_utc INTEGER, tags TEXT NOT NULL CHECK(json_valid(tags)) diff --git a/src/animaltrack/events/enums.py b/src/animaltrack/events/enums.py index 1a0cba4..0ece02d 100644 --- a/src/animaltrack/events/enums.py +++ b/src/animaltrack/events/enums.py @@ -26,7 +26,6 @@ class LifeStage(str, Enum): HATCHLING = "hatchling" JUVENILE = "juvenile" - SUBADULT = "subadult" ADULT = "adult" diff --git a/src/animaltrack/id_gen.py b/src/animaltrack/id_gen.py index 66a936f..bbc7c08 100644 --- a/src/animaltrack/id_gen.py +++ b/src/animaltrack/id_gen.py @@ -1,8 +1,18 @@ # ABOUTME: ULID generation utility for creating unique identifiers. -# ABOUTME: Provides a simple wrapper around the python-ulid library. +# ABOUTME: Provides phonetic encoding for human-readable animal ID display. from ulid import ULID +# Consonant-Vowel syllables for phonetic encoding +# 16 consonants x 5 vowels = 80 syllables +# We avoid confusing letters: c/k, j/g, q/k, h (silent), y (sometimes vowel) +_CONSONANTS = "bdfgklmnprstvwxz" # 16 consonants +_VOWELS = "aeiou" # 5 vowels + +# Build syllable lookup table (80 entries, ~6.3 bits each) +_SYLLABLES = tuple(c + v for c in _CONSONANTS for v in _VOWELS) +_SYLLABLE_TO_INDEX = {syl: i for i, syl in enumerate(_SYLLABLES)} + def generate_id() -> str: """Generate a new ULID as a 26-character string. @@ -11,3 +21,56 @@ def generate_id() -> str: A 26-character uppercase alphanumeric ULID string. """ return str(ULID()) + + +def ulid_to_phonetic(ulid_str: str, syllable_count: int = 4) -> str: + """Convert ULID to phonetic representation. + + Uses the random portion of the ULID (bytes 6-16) since the first + 6 bytes are timestamp and would be identical for IDs generated + close together. With 4 syllables using 80 syllables each, encodes + ~25 bits (about 40 million unique values). + + Args: + ulid_str: 26-character ULID string. + syllable_count: Number of syllables (default 4). + + Returns: + Hyphenated phonetic string like "tobi-kafu-meli-dova". + """ + # Parse ULID to get raw bytes + ulid = ULID.from_str(ulid_str) + ulid_bytes = ulid.bytes # 16 bytes = 128 bits + + # Skip the first 6 bytes (timestamp) and use the random portion + # ULID format: 48 bits timestamp (6 bytes) + 80 bits random (10 bytes) + random_bytes = ulid_bytes[6:] # 10 bytes of randomness + + # Convert random bytes to integer for extraction + value = int.from_bytes(random_bytes, "big") + + # Extract syllables using modular arithmetic + result = [] + for _ in range(syllable_count): + idx = value % 80 + result.append(_SYLLABLES[idx]) + value //= 80 + + return "-".join(result) + + +def format_animal_id(animal_id: str, nickname: str | None = None) -> str: + """Format animal ID for display. + + Returns nickname if available, otherwise phonetic encoding. + + Args: + animal_id: The 26-character ULID. + nickname: Optional animal nickname. + + Returns: + Display string for the animal. + """ + if nickname: + return nickname + return ulid_to_phonetic(animal_id) diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 6b091e1..2f10e8d 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -18,6 +18,7 @@ from animaltrack.web.exceptions import AuthenticationError, AuthorizationError from animaltrack.web.middleware import ( auth_before, csrf_before, + generate_csrf_token, request_id_before, ) from animaltrack.web.responses import error_toast @@ -105,6 +106,39 @@ def create_app( return None + def after(req: Request, resp, sess): + """Afterware to set CSRF cookie on page responses.""" + # Skip in dev mode (CSRF is bypassed anyway) + if settings.dev_mode: + return resp + + # Only set cookie on GET requests (page loads) + if req.method != "GET": + return resp + + # Check if cookie already set + if settings.csrf_cookie_name in req.cookies: + return resp + + # Skip non-HTML responses (API endpoints, static files, etc.) + content_type = resp.headers.get("content-type", "") + if "text/html" not in content_type: + return resp + + # Generate and set token + token = generate_csrf_token(settings.csrf_secret) + + # Set cookie - httponly=False so JS can read it for HTMX + resp.set_cookie( + settings.csrf_cookie_name, + token, + httponly=False, + samesite="strict", + secure=not settings.dev_mode, + max_age=86400, # 24 hours + ) + return resp + # Configure beforeware with skip patterns beforeware = Beforeware( before, @@ -139,6 +173,7 @@ def create_app( # Create FastHTML app with HTMX extensions, MonsterUI theme, and static path app, rt = fast_app( before=beforeware, + after=after, hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config exts=["head-support", "preload"], static_path=static_path_for_fasthtml, diff --git a/src/animaltrack/web/middleware.py b/src/animaltrack/web/middleware.py index e99634f..16fae1f 100644 --- a/src/animaltrack/web/middleware.py +++ b/src/animaltrack/web/middleware.py @@ -1,8 +1,11 @@ # ABOUTME: Middleware functions for authentication, CSRF, and request logging. # ABOUTME: Implements Beforeware pattern for FastHTML request processing. +import hashlib +import hmac import ipaddress import json +import secrets import time from urllib.parse import urlparse @@ -18,6 +21,27 @@ from animaltrack.repositories.users import UserRepository SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +def generate_csrf_token(secret: str) -> str: + """Generate a CSRF token using HMAC. + + Creates a random nonce and signs it with the secret to produce a token. + The token format is: nonce:signature (hex encoded). + + Args: + secret: The csrf_secret from settings. + + Returns: + A token string in format "nonce:signature". + """ + nonce = secrets.token_hex(16) + signature = hmac.new( + secret.encode("utf-8"), + nonce.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{nonce}:{signature}" + + def is_safe_method(method: str) -> bool: """Check if HTTP method is safe (doesn't require CSRF protection). diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index f87a346..d41a3cf 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -159,7 +159,7 @@ async def animal_cohort(request: Request, session): return _render_cohort_error( request, locations, species_list, "Please select a location", form ) - if not life_stage or life_stage not in ("hatchling", "juvenile", "subadult", "adult"): + if not life_stage or life_stage not in ("hatchling", "juvenile", "adult"): return _render_cohort_error( request, locations, species_list, "Please select a life stage", form ) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index 9b50d3d..12d8d69 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -174,7 +174,6 @@ def cohort_form( life_stages = [ ("hatchling", "Hatchling"), ("juvenile", "Juvenile"), - ("subadult", "Subadult"), ("adult", "Adult"), ] life_stage_options = [ @@ -1037,7 +1036,6 @@ def attrs_form( Option("No change", value="", selected=True), Option("Hatchling", value="hatchling"), Option("Juvenile", value="juvenile"), - Option("Subadult", value="subadult"), Option("Adult", value="adult"), ] @@ -1282,7 +1280,7 @@ def outcome_form( # Build outcome options outcome_options = [ Option("Select outcome...", value="", selected=True, disabled=True), - Option("Death (natural)", value="death"), + Option("Death", value="death"), Option("Harvest", value="harvest"), Option("Sold", value="sold"), Option("Predator Loss", value="predator_loss"), diff --git a/src/animaltrack/web/templates/animal_select.py b/src/animaltrack/web/templates/animal_select.py index 76c0cf9..ffb5504 100644 --- a/src/animaltrack/web/templates/animal_select.py +++ b/src/animaltrack/web/templates/animal_select.py @@ -3,6 +3,7 @@ from fasthtml.common import Div, Input, Label, P, Span +from animaltrack.id_gen import format_animal_id from animaltrack.repositories.animals import AnimalListItem @@ -29,7 +30,12 @@ def animal_checkbox_list( items = [] for animal in animals[:max_display]: is_checked = animal.animal_id in selected_set - display_name = animal.nickname or animal.animal_id[:8] + "..." + display_name = format_animal_id(animal.animal_id, animal.nickname) + + # Format sex as single letter (M/F/?) + sex_code = {"male": "M", "female": "F", "unknown": "?"}.get(animal.sex, "?") + # Format life stage as abbreviation + stage_abbr = animal.life_stage[:3].title() # hat, juv, adu items.append( Label( @@ -43,7 +49,7 @@ def animal_checkbox_list( ), Span(display_name, cls="text-stone-200"), Span( - f" ({animal.species_code}, {animal.location_name})", + f" ({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})", cls="text-stone-500 text-sm", ), cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer", diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index a49d5f7..287678c 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -68,6 +68,37 @@ def EventSlideOverScript(): # noqa: N802 """) +def CsrfHeaderScript(): # noqa: N802 + """JavaScript to inject CSRF token into HTMX requests. + + Reads the csrf_token cookie and adds it as x-csrf-token header + to all HTMX requests. This is required for POST/PUT/DELETE + requests to pass CSRF validation. + """ + return Script(""" + // Read CSRF token from cookie + function getCsrfToken() { + var name = 'csrf_token='; + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + if (cookie.indexOf(name) === 0) { + return cookie.substring(name.length); + } + } + return ''; + } + + // Configure HTMX to send CSRF token with all requests + document.body.addEventListener('htmx:configRequest', function(event) { + var token = getCsrfToken(); + if (token) { + event.detail.headers['x-csrf-token'] = token; + } + }); + """) + + def EventSlideOver(): # noqa: N802 """Event detail slide-over panel container.""" return Div( @@ -127,6 +158,7 @@ def page( EventSlideOverStyles(), SidebarScript(), EventSlideOverScript(), + CsrfHeaderScript(), # Desktop sidebar Sidebar(active_nav=active_nav, user_role=user_role, username=username), # Mobile menu drawer diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py index 6c61a52..5ebe28a 100644 --- a/src/animaltrack/web/templates/event_detail.py +++ b/src/animaltrack/web/templates/event_detail.py @@ -6,6 +6,7 @@ from typing import Any from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul +from animaltrack.id_gen import format_animal_id from animaltrack.models.events import Event from animaltrack.models.reference import UserRole @@ -361,7 +362,7 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div: animal_items = [] for animal in animals[:20]: # Limit display - display_name = animal.get("nickname") or animal["id"][:8] + "..." + display_name = format_animal_id(animal["id"], animal.get("nickname")) animal_items.append( Li( A( diff --git a/src/animaltrack/web/templates/registry.py b/src/animaltrack/web/templates/registry.py index 1147822..5e802dc 100644 --- a/src/animaltrack/web/templates/registry.py +++ b/src/animaltrack/web/templates/registry.py @@ -6,8 +6,9 @@ from typing import Any from urllib.parse import urlencode from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr -from monsterui.all import Button, ButtonT, Card, Grid, LabelInput +from monsterui.all import Button, ButtonT, Grid, LabelInput +from animaltrack.id_gen import format_animal_id from animaltrack.models.reference import Location, Species from animaltrack.repositories.animals import AnimalListItem, FacetCounts @@ -20,8 +21,8 @@ def registry_page( total_count: int = 0, locations: list[Location] | None = None, species_list: list[Species] | None = None, -) -> Grid: - """Full registry page with sidebar and table. +) -> Div: + """Full registry page with filter at top, then sidebar + table. Args: animals: List of animals for the current page. @@ -33,27 +34,30 @@ def registry_page( species_list: List of species for facet labels. Returns: - Grid component with sidebar and main content. + Div component with header, sidebar, and main content. """ - return Grid( - # Sidebar with facets - facet_sidebar(facets, filter_str, locations, species_list), - # Main content - Div( - # Header with filter - registry_header(filter_str, total_count), - # Animal table - animal_table(animals, next_cursor, filter_str), - cls="col-span-3", + return Div( + # Filter at top - full width + registry_header(filter_str, total_count), + # Grid with sidebar and table + Grid( + # Sidebar with facets + facet_sidebar(facets, filter_str, locations, species_list), + # Main content - table + Div( + animal_table(animals, next_cursor, filter_str), + cls="col-span-3", + ), + cols_sm=1, + cols_md=4, + cls="gap-4", ), - cols_sm=1, - cols_md=4, - cls="gap-4 p-4", + cls="p-4", ) def registry_header(filter_str: str, total_count: int) -> Div: - """Header with title and filter input. + """Header with title, count, and prominent filter. Args: filter_str: Current filter string. @@ -63,30 +67,34 @@ def registry_header(filter_str: str, total_count: int) -> Div: Div with header and filter form. """ return Div( + # Top row: Title and count Div( H2("Animal Registry", cls="text-xl font-bold"), - Span(f"{total_count} animals", cls="text-sm text-stone-400"), - cls="flex items-center justify-between", + Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"), + cls="flex items-baseline mb-4", ), - # Filter form + # Filter form - full width, prominent Form( Div( - LabelInput( - "Filter", - id="filter", - name="filter", - value=filter_str, - placeholder='e.g., species:duck status:alive location:"Strip 1"', - cls="flex-1", + # Filter input - wider with flex-grow + Div( + LabelInput( + "Filter", + id="filter", + name="filter", + value=filter_str, + placeholder='species:duck status:alive location:"Strip 1"', + ), + cls="flex-1 min-w-0", # min-w-0 prevents flex overflow ), - Button("Apply", type="submit", cls=ButtonT.primary), - cls="flex gap-2 items-end", + # Apply button - fixed size + Button("Apply", type="submit", cls=f"{ButtonT.primary} shrink-0"), + cls="flex gap-3 items-end", ), action="/registry", method="get", - cls="mt-4", ), - cls="mb-4", + cls="mb-6 pb-4 border-b border-stone-700", ) @@ -96,7 +104,7 @@ def facet_sidebar( locations: list[Location] | None, species_list: list[Species] | None, ) -> Div: - """Sidebar with clickable facet counts. + """Sidebar with compact clickable facet counts. Args: facets: Facet counts for display. @@ -116,7 +124,7 @@ def facet_sidebar( facet_section("Sex", facets.by_sex, filter_str, "sex"), facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"), facet_section("Location", facets.by_location, filter_str, "location", location_map), - cls="space-y-4", + cls="space-y-3", ) @@ -127,7 +135,7 @@ def facet_section( field: str, label_map: dict[str, str] | None = None, ) -> Any: - """Single facet section with clickable items. + """Single facet section with compact pill-style items. Args: title: Section title. @@ -137,11 +145,12 @@ def facet_section( label_map: Optional mapping from value to display label. Returns: - Card component with facet items, or None if no counts. + Div component with facet pills, or None if no counts. """ if not counts: return None + # Build inline pill items items = [] for value, count in sorted(counts.items(), key=lambda x: -x[1]): label = label_map.get(value, value) if label_map else value.replace("_", " ").title() @@ -153,19 +162,19 @@ def facet_section( href = f"/registry?{urlencode({'filter': new_filter})}" items.append( A( - Div( - Span(label, cls="text-sm"), - Span(str(count), cls="text-xs text-stone-400 ml-auto"), - cls="flex justify-between items-center", - ), + Span(label, cls="text-xs"), + Span(str(count), cls="text-xs text-stone-500 ml-1"), href=href, - cls="block p-2 hover:bg-slate-800 rounded", + cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 hover:bg-stone-700 mr-1 mb-1", ) ) - return Card( - P(title, cls="font-bold text-sm mb-2"), - *items, + return Div( + P(title, cls="font-semibold text-xs text-stone-400 mb-2"), + Div( + *items, + cls="flex flex-wrap", + ), ) @@ -220,8 +229,8 @@ def animal_row(animal: AnimalListItem) -> Tr: last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC) last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M") - # Display ID (truncated or nickname) - display_id = animal.nickname or animal.animal_id[:8] + "..." + # Display ID (phonetic encoding or nickname) + display_id = format_animal_id(animal.animal_id, animal.nickname) # Status badge styling status_cls = { diff --git a/tests/test_event_payloads.py b/tests/test_event_payloads.py index be509d2..17e6dc3 100644 --- a/tests/test_event_payloads.py +++ b/tests/test_event_payloads.py @@ -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): diff --git a/tests/test_id_gen.py b/tests/test_id_gen.py index fabb47b..994f022 100644 --- a/tests/test_id_gen.py +++ b/tests/test_id_gen.py @@ -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 diff --git a/tests/test_migration_animal_registry.py b/tests/test_migration_animal_registry.py index 23d5070..c9597da 100644 --- a/tests/test_migration_animal_registry.py +++ b/tests/test_migration_animal_registry.py @@ -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) diff --git a/tests/test_web_csrf.py b/tests/test_web_csrf.py index 0fdeef7..18fa903 100644 --- a/tests/test_web_csrf.py +++ b/tests/test_web_csrf.py @@ -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: