diff --git a/src/backend/config.py b/src/backend/config.py index 5c2a1b0..b967388 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -1,22 +1,37 @@ """TieMeasureFlow Server Configuration.""" from pathlib import Path + +from pydantic import model_validator from pydantic_settings import BaseSettings +# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a +# real deployment that still has these is running on secrets anyone can read +# in this file's git history. +_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key" +_DEFAULT_DB_PASSWORD = "change_me_in_production" + class Settings(BaseSettings): """Application settings loaded from environment variables.""" + # Deployment environment. "dev" and "test" skip the production-only guard + # below; anything else (e.g. "production") is treated as a real + # deployment. Local dev and the test suite must keep starting with no + # .env at all, which is why this defaults to "dev" rather than requiring + # the variable. + app_env: str = "dev" + # Database db_host: str = "localhost" db_port: int = 3306 db_name: str = "tiemeasureflow" db_user: str = "tmflow" - db_password: str = "change_me_in_production" + db_password: str = _DEFAULT_DB_PASSWORD # Server server_host: str = "0.0.0.0" server_port: int = 8000 - server_secret_key: str = "change-this-to-a-random-secret-key" + server_secret_key: str = _DEFAULT_SECRET_KEY server_cors_origins: str = "http://localhost:5000" # Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives # in process memory, so this is how RateLimitMiddleware divides the @@ -86,5 +101,27 @@ class Settings(BaseSettings): "extra": "ignore", } + @model_validator(mode="after") + def _refuse_default_secrets_outside_dev(self) -> "Settings": + """Refuse to boot with a committed default secret in a real deployment. + + Only fires when APP_ENV names an actual deployment (not "dev"/"test"), + so local development and the test suite keep starting with no .env at + all. + """ + if self.app_env in ("dev", "test"): + return self + if self.server_secret_key == _DEFAULT_SECRET_KEY: + raise RuntimeError( + "SERVER_SECRET_KEY is still the committed default value; " + "set it before running with APP_ENV=" + self.app_env + ) + if self.db_password == _DEFAULT_DB_PASSWORD: + raise RuntimeError( + "DB_PASSWORD is still the committed default value; " + "set it before running with APP_ENV=" + self.app_env + ) + return self + settings = Settings() diff --git a/src/backend/tests/test_config.py b/src/backend/tests/test_config.py new file mode 100644 index 0000000..f27aebc --- /dev/null +++ b/src/backend/tests/test_config.py @@ -0,0 +1,50 @@ +"""Tests for Settings' production-secret guard (issue #33, backend part).""" +import pytest + +from src.backend.config import Settings, _DEFAULT_DB_PASSWORD, _DEFAULT_SECRET_KEY + +_REAL_SECRET_KEY = "a-real-generated-secret-key-not-the-default" +_REAL_DB_PASSWORD = "a-real-generated-db-password-not-the-default" + + +def _settings(**overrides) -> Settings: + """Build a Settings instance without reading the real .env file or + inheriting stray SERVER_SECRET_KEY/DB_PASSWORD from the shell env.""" + overrides.setdefault("server_secret_key", _DEFAULT_SECRET_KEY) + overrides.setdefault("db_password", _DEFAULT_DB_PASSWORD) + return Settings(_env_file=None, **overrides) + + +class TestProductionSecretGuard: + def test_dev_env_allows_default_secrets(self): + s = _settings(app_env="dev") + assert s.server_secret_key == _DEFAULT_SECRET_KEY + assert s.db_password == _DEFAULT_DB_PASSWORD + + def test_test_env_allows_default_secrets(self): + s = _settings(app_env="test") + assert s.server_secret_key == _DEFAULT_SECRET_KEY + + def test_production_env_rejects_default_secret_key(self): + with pytest.raises(RuntimeError, match="SERVER_SECRET_KEY"): + _settings(app_env="production", db_password=_REAL_DB_PASSWORD) + + def test_production_env_rejects_default_db_password(self): + with pytest.raises(RuntimeError, match="DB_PASSWORD"): + _settings(app_env="production", server_secret_key=_REAL_SECRET_KEY) + + def test_production_env_accepts_real_secrets(self): + s = _settings( + app_env="production", + server_secret_key=_REAL_SECRET_KEY, + db_password=_REAL_DB_PASSWORD, + ) + assert s.app_env == "production" + + def test_module_level_settings_instance_starts_without_env(self): + """The already-instantiated `settings` (imported at process start) + must not have raised - this is what "tests/dev start without .env" + requires: APP_ENV defaults to "dev", so the guard never fires here.""" + from src.backend.config import settings + + assert settings.app_env == "dev"