diff --git a/PLAN.md b/PLAN.md index 9fd7c2c..a8fd830 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,12 +7,12 @@ Check off items as completed. Each phase builds on the previous. ## Phase 1: Foundation ### Step 1.1: Project Structure & Configuration -- [ ] Create Python package structure (`src/animaltrack/`) -- [ ] Create `pyproject.toml` with dependencies -- [ ] Create `flake.nix` with dev environment -- [ ] Create `.envrc`, `.gitignore` -- [ ] Implement `config.py` with all env vars from spec §18 -- [ ] Write tests for config loading and validation +- [x] Create Python package structure (`src/animaltrack/`) +- [x] Create `pyproject.toml` with dependencies +- [x] Create `flake.nix` with dev environment +- [x] Create `.envrc`, `.gitignore` +- [x] Implement `config.py` with all env vars from spec §18 +- [x] Write tests for config loading and validation - [ ] **Commit checkpoint** ### Step 1.2: Database Connection & Pragmas diff --git a/pyproject.toml b/pyproject.toml index 001f035..5c32707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ ignore = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] python_files = "test_*.py" python_classes = "Test*" python_functions = "test_*" diff --git a/src/animaltrack/config.py b/src/animaltrack/config.py new file mode 100644 index 0000000..739d1e4 --- /dev/null +++ b/src/animaltrack/config.py @@ -0,0 +1,65 @@ +# ABOUTME: Application configuration loaded from environment variables. +# ABOUTME: Uses Pydantic Settings for validation and type coercion. + +import logging +from functools import cached_property + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings + +VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +def _parse_comma_separated(value: str) -> list[str]: + """Parse a comma-separated string into a list of trimmed strings.""" + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Server configuration + port: int = 3366 # EGG in leet: 3=E, 6=G, 6=G + db_path: str = "animaltrack.db" + base_path: str = "/" + + # Authentication + auth_header_name: str = "X-Oidc-Username" + trusted_proxy_ips_raw: str = Field(default="", validation_alias="trusted_proxy_ips") + + # CSRF protection + csrf_secret: str # Required, no default + csrf_cookie_name: str = "csrf_token" + + # Application behavior + seed_on_start: bool = False + log_level: str = "INFO" + metrics_enabled: bool = False + + @cached_property + def trusted_proxy_ips(self) -> list[str]: + """Parse trusted proxy IPs from comma-separated raw string.""" + return _parse_comma_separated(self.trusted_proxy_ips_raw) + + @field_validator("log_level", mode="before") + @classmethod + def normalize_and_validate_log_level(cls, v: str) -> str: + """Normalize log level to uppercase and validate it's a valid Python log level.""" + if v is None: + return "INFO" + normalized = v.upper() + if normalized not in VALID_LOG_LEVELS: + msg = f"Invalid log level: {v}. Must be one of: {', '.join(sorted(VALID_LOG_LEVELS))}" + raise ValueError(msg) + return normalized + + def get_numeric_log_level(self) -> int: + """Get the numeric log level for use with logging module.""" + return getattr(logging, self.log_level) + + model_config = { + "env_prefix": "", + "case_sensitive": False, + } diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7fd9982 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,297 @@ +# ABOUTME: Tests for the configuration module. +# ABOUTME: Validates environment variable loading, defaults, and validation. + +import os +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + + +class TestConfigDefaults: + """Test that config loads with correct default values.""" + + def test_default_port(self): + """Default port should be 3366 (EGG in leet).""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.port == 3366 + + def test_default_db_path(self): + """Default DB path should be animaltrack.db.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.db_path == "animaltrack.db" + + def test_default_auth_header_name(self): + """Default auth header should be X-Oidc-Username.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.auth_header_name == "X-Oidc-Username" + + def test_default_trusted_proxy_ips_empty(self): + """Default trusted proxy IPs should be empty list.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.trusted_proxy_ips == [] + + def test_default_csrf_cookie_name(self): + """Default CSRF cookie name should be csrf_token.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.csrf_cookie_name == "csrf_token" + + def test_default_base_path(self): + """Default base path should be /.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.base_path == "/" + + def test_default_seed_on_start(self): + """Default seed on start should be False.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.seed_on_start is False + + def test_default_log_level(self): + """Default log level should be INFO.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.log_level == "INFO" + + def test_default_metrics_enabled(self): + """Default metrics enabled should be False.""" + with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.metrics_enabled is False + + +class TestConfigEnvOverrides: + """Test that environment variables override defaults.""" + + def test_port_override(self): + """PORT env var should override default.""" + with patch.dict(os.environ, {"PORT": "8080", "CSRF_SECRET": "test-secret"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.port == 8080 + + def test_db_path_override(self): + """DB_PATH env var should override default.""" + with patch.dict( + os.environ, {"DB_PATH": "/data/custom.db", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.db_path == "/data/custom.db" + + def test_auth_header_name_override(self): + """AUTH_HEADER_NAME env var should override default.""" + with patch.dict( + os.environ, + {"AUTH_HEADER_NAME": "X-Custom-User", "CSRF_SECRET": "test-secret"}, + clear=True, + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.auth_header_name == "X-Custom-User" + + def test_csrf_cookie_name_override(self): + """CSRF_COOKIE_NAME env var should override default.""" + with patch.dict( + os.environ, {"CSRF_COOKIE_NAME": "my_csrf", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.csrf_cookie_name == "my_csrf" + + def test_base_path_override(self): + """BASE_PATH env var should override default.""" + with patch.dict( + os.environ, {"BASE_PATH": "/app", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.base_path == "/app" + + def test_seed_on_start_true(self): + """SEED_ON_START=true should set to True.""" + with patch.dict( + os.environ, {"SEED_ON_START": "true", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.seed_on_start is True + + def test_seed_on_start_false(self): + """SEED_ON_START=false should set to False.""" + with patch.dict( + os.environ, {"SEED_ON_START": "false", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.seed_on_start is False + + def test_log_level_override(self): + """LOG_LEVEL env var should override default.""" + with patch.dict( + os.environ, {"LOG_LEVEL": "DEBUG", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.log_level == "DEBUG" + + def test_metrics_enabled_true(self): + """METRICS_ENABLED=true should set to True.""" + with patch.dict( + os.environ, {"METRICS_ENABLED": "true", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.metrics_enabled is True + + +class TestTrustedProxyIPs: + """Test TRUSTED_PROXY_IPS parsing.""" + + def test_single_ip(self): + """Single IP should be parsed as list with one element.""" + with patch.dict( + os.environ, + {"TRUSTED_PROXY_IPS": "192.168.1.1", "CSRF_SECRET": "test-secret"}, + clear=True, + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.trusted_proxy_ips == ["192.168.1.1"] + + def test_multiple_ips_comma_separated(self): + """Multiple IPs should be parsed from comma-separated string.""" + with patch.dict( + os.environ, + {"TRUSTED_PROXY_IPS": "192.168.1.1,10.0.0.1,172.16.0.1", "CSRF_SECRET": "test-secret"}, + clear=True, + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.trusted_proxy_ips == ["192.168.1.1", "10.0.0.1", "172.16.0.1"] + + def test_ips_with_spaces_trimmed(self): + """Spaces around IPs should be trimmed.""" + with patch.dict( + os.environ, + {"TRUSTED_PROXY_IPS": " 192.168.1.1 , 10.0.0.1 ", "CSRF_SECRET": "test-secret"}, + clear=True, + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.trusted_proxy_ips == ["192.168.1.1", "10.0.0.1"] + + def test_empty_string_gives_empty_list(self): + """Empty string should give empty list.""" + with patch.dict( + os.environ, {"TRUSTED_PROXY_IPS": "", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.trusted_proxy_ips == [] + + +class TestCsrfSecretRequired: + """Test that CSRF_SECRET is required.""" + + def test_missing_csrf_secret_raises_error(self): + """Missing CSRF_SECRET should raise ValidationError.""" + with patch.dict(os.environ, {}, clear=True): + from animaltrack.config import Settings + + with pytest.raises(ValidationError) as exc_info: + Settings() + # Check that the error is about csrf_secret + errors = exc_info.value.errors() + assert any(e["loc"] == ("csrf_secret",) for e in errors) + + def test_csrf_secret_provided(self): + """Provided CSRF_SECRET should be accessible.""" + with patch.dict(os.environ, {"CSRF_SECRET": "my-super-secret-key"}, clear=True): + from animaltrack.config import Settings + + settings = Settings() + assert settings.csrf_secret == "my-super-secret-key" + + +class TestValidation: + """Test validation of config values.""" + + def test_invalid_port_not_integer(self): + """Non-integer PORT should raise ValidationError.""" + with patch.dict( + os.environ, {"PORT": "not-a-number", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + with pytest.raises(ValidationError): + Settings() + + def test_invalid_log_level(self): + """Invalid LOG_LEVEL should raise ValidationError.""" + with patch.dict( + os.environ, {"LOG_LEVEL": "INVALID_LEVEL", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + with pytest.raises(ValidationError): + Settings() + + def test_valid_log_levels(self): + """All standard log levels should be valid.""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + for level in valid_levels: + with patch.dict( + os.environ, {"LOG_LEVEL": level, "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.log_level == level + + def test_log_level_case_insensitive(self): + """Log level should be case-insensitive (normalized to uppercase).""" + with patch.dict( + os.environ, {"LOG_LEVEL": "debug", "CSRF_SECRET": "test-secret"}, clear=True + ): + from animaltrack.config import Settings + + settings = Settings() + assert settings.log_level == "DEBUG" diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index ad7d3ea..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,7 +0,0 @@ -# ABOUTME: Placeholder test to ensure test infrastructure works. -# ABOUTME: Remove once real tests are added. - - -def test_placeholder(): - """Placeholder test - remove when real tests exist.""" - assert True