diff --git a/receiptwitness/src/receiptwitness/config.py b/receiptwitness/src/receiptwitness/config.py index 358b965..b9d2574 100644 --- a/receiptwitness/src/receiptwitness/config.py +++ b/receiptwitness/src/receiptwitness/config.py @@ -1,8 +1,12 @@ """Service-specific configuration for ReceiptWitness.""" +from pydantic import model_validator from pydantic_settings import BaseSettings +_PLACEHOLDER_VALUES = {"change-me-in-production"} + + class ReceiptWitnessSettings(BaseSettings): model_config = {"env_prefix": "RW_"} @@ -30,5 +34,34 @@ class ReceiptWitnessSettings(BaseSettings): # Mailgun inbound email webhook mailgun_webhook_signing_key: str = "" + @model_validator(mode="after") + def validate_required_vars(self): + errors = [] + if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES: + errors.append( + "RW_SESSION_ENCRYPTION_KEY must be set to a secure value. " + 'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' + ) + if self.notifications_enabled and not self.resend_api_key: + errors.append( + "RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. " + "Get an API key from https://resend.com/api-keys" + ) + if errors: + raise ValueError( + "ReceiptWitness startup failed — missing required config:\n" + + "\n".join(f" - {e}" for e in errors) + ) + return self -settings = ReceiptWitnessSettings() + +class _LazySettings: + _instance: ReceiptWitnessSettings | None = None + + def __getattr__(self, name: str): + if _LazySettings._instance is None: + _LazySettings._instance = ReceiptWitnessSettings() + return getattr(_LazySettings._instance, name) + + +settings = _LazySettings() diff --git a/receiptwitness/tests/conftest.py b/receiptwitness/tests/conftest.py index a8b29ba..45a30cf 100644 --- a/receiptwitness/tests/conftest.py +++ b/receiptwitness/tests/conftest.py @@ -1,12 +1,16 @@ """Shared test fixtures.""" import json +import os from pathlib import Path import pytest FIXTURES_DIR = Path(__file__).parent / "fixtures" +os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!") +os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key") + @pytest.fixture def meijer_receipt_data() -> dict: diff --git a/receiptwitness/tests/test_config.py b/receiptwitness/tests/test_config.py new file mode 100644 index 0000000..059573b --- /dev/null +++ b/receiptwitness/tests/test_config.py @@ -0,0 +1,46 @@ +import pytest +from receiptwitness.config import ReceiptWitnessSettings + + +def test_valid_config(): + s = ReceiptWitnessSettings( + session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=" + ) + assert s.session_encryption_key + + +def test_missing_session_encryption_key_raises(): + with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"): + ReceiptWitnessSettings(session_encryption_key="") + + +def test_placeholder_session_encryption_key_raises(): + with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"): + ReceiptWitnessSettings(session_encryption_key="change-me-in-production") + + +def test_notifications_enabled_without_resend_key_raises(): + with pytest.raises(ValueError, match="RW_RESEND_API_KEY"): + ReceiptWitnessSettings( + session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=", + notifications_enabled=True, + resend_api_key="", + ) + + +def test_notifications_disabled_without_resend_key_ok(): + s = ReceiptWitnessSettings( + session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=", + notifications_enabled=False, + resend_api_key="", + ) + assert s.notifications_enabled is False + + +def test_notifications_enabled_with_resend_key_ok(): + s = ReceiptWitnessSettings( + session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=", + notifications_enabled=True, + resend_api_key="re_test_1234567890", + ) + assert s.resend_api_key == "re_test_1234567890"