"""Tests for the configuration reader in ``timetracker/config.py``.""" import pytest from django.core.exceptions import DisallowedHost, ImproperlyConfigured from django.middleware.csrf import CsrfViewMiddleware from django.test import RequestFactory, override_settings from timetracker import config as config_module from timetracker.config import config, derive_hosts_and_origins @pytest.fixture(autouse=True) def _clear_caches(): """Each test sees freshly parsed files.""" config_module.reset_caches() yield config_module.reset_caches() @pytest.fixture def env_file(tmp_path, monkeypatch): def _write(contents: str): path = tmp_path / ".env" path.write_text(contents) monkeypatch.setenv("ENV_FILE", str(path)) config_module.reset_caches() return path return _write @pytest.fixture def ini_file(tmp_path, monkeypatch): def _write(contents: str): path = tmp_path / "settings.ini" path.write_text(contents) monkeypatch.setenv("INI_FILE", str(path)) config_module.reset_caches() return path return _write def test_default_returned_when_unset(): assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback" def test_missing_without_default_raises(): with pytest.raises(ImproperlyConfigured): config("TOTALLY_UNSET_VALUE") def test_env_var_overrides_default(monkeypatch): monkeypatch.setenv("SOME_SETTING", "from-env") assert config("SOME_SETTING", default="fallback") == "from-env" def test_priority_env_beats_files(monkeypatch, env_file, ini_file): ini_file("[timetracker]\nVALUE = from-ini\n") env_file("VALUE=from-dotenv\n") monkeypatch.setenv("VALUE", "from-env") assert config("VALUE") == "from-env" def test_priority_dotenv_beats_ini(env_file, ini_file): ini_file("[timetracker]\nVALUE = from-ini\n") env_file("VALUE=from-dotenv\n") assert config("VALUE") == "from-dotenv" def test_priority_ini_beats_default(ini_file): ini_file("[timetracker]\nVALUE = from-ini\n") assert config("VALUE", default="fallback") == "from-ini" def test_ini_preserves_key_case(ini_file): ini_file("[timetracker]\nSECRET_KEY = abc\n") assert config("SECRET_KEY") == "abc" # --- __FILE secret pointer ------------------------------------------------- def test_file_pointer_read_and_stripped(tmp_path, monkeypatch): secret = tmp_path / "secret" secret.write_text("super-secret-value\n") # trailing newline must be stripped monkeypatch.setenv("SECRET_KEY__FILE", str(secret)) assert config("SECRET_KEY", allow_file=True) == "super-secret-value" def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch): secret = tmp_path / "secret" secret.write_text("ignored") monkeypatch.setenv("SECRET_KEY__FILE", str(secret)) assert config("SECRET_KEY", default="fallback") == "fallback" def test_file_pointer_beats_env(tmp_path, monkeypatch): secret = tmp_path / "secret" secret.write_text("from-file") monkeypatch.setenv("SECRET_KEY__FILE", str(secret)) monkeypatch.setenv("SECRET_KEY", "from-env") assert config("SECRET_KEY", allow_file=True) == "from-file" # --- casting --------------------------------------------------------------- @pytest.mark.parametrize( "raw,expected", [ ("true", True), ("True", True), ("1", True), ("yes", True), ("on", True), ("false", False), ("0", False), ("no", False), ("", False), ], ) def test_cast_bool(monkeypatch, raw, expected): monkeypatch.setenv("FLAG", raw) assert config("FLAG", cast=bool) is expected def test_cast_list(monkeypatch): monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example") assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"] def test_cast_int(monkeypatch): monkeypatch.setenv("COUNT", "42") assert config("COUNT", cast=int) == 42 def test_cast_not_applied_to_default(): # A None default passes through untouched even with a cast set. assert config("UNSET", default=None, cast=list) is None # --- required_in_prod ------------------------------------------------------ def test_required_in_prod_raises_when_prod(monkeypatch): monkeypatch.setenv("DEBUG", "false") with pytest.raises(ImproperlyConfigured): config("SECRET_KEY", default="dev-default", required_in_prod=True) def test_required_in_prod_uses_default_in_debug(monkeypatch): monkeypatch.setenv("DEBUG", "true") assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == ( "dev-default" ) def test_deprecated_prod_var_implies_production(monkeypatch): monkeypatch.delenv("DEBUG", raising=False) monkeypatch.setenv("PROD", "1") with pytest.raises(ImproperlyConfigured): config("SECRET_KEY", default="dev-default", required_in_prod=True) # --- .env parser edge cases ------------------------------------------------ def test_env_parser_quotes_comments_and_export(env_file): env_file( "\n".join( [ "# a comment line", "PLAIN=value", "export EXPORTED=exported-value", 'DOUBLE="quoted value"', "SINGLE='single quoted'", "INLINE=value # trailing comment", 'HASH_IN_QUOTES="a # b"', "EMPTY=", 'QUOTED_THEN_COMMENT="keep" # drop', ] ) + "\n" ) assert config("PLAIN") == "value" assert config("EXPORTED") == "exported-value" assert config("DOUBLE") == "quoted value" assert config("SINGLE") == "single quoted" assert config("INLINE") == "value" assert config("HASH_IN_QUOTES") == "a # b" assert config("EMPTY", default="x") == "" assert config("QUOTED_THEN_COMMENT") == "keep" def test_missing_files_are_ignored(monkeypatch, tmp_path): monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env")) monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini")) config_module.reset_caches() assert config("ANYTHING", default="fallback") == "fallback" # --- derive_hosts_and_origins ----------------------------------------------- def test_single_url_derives_one_host_and_origin(): hosts, origins = derive_hosts_and_origins("https://tracker.example.com") assert hosts == ["tracker.example.com"] assert origins == ["https://tracker.example.com"] def test_multiple_urls_derive_multiple_hosts_and_origins(): hosts, origins = derive_hosts_and_origins( "https://tracker.example.com,https://www.tracker.example.com" ) assert hosts == ["tracker.example.com", "www.tracker.example.com"] assert origins == ["https://tracker.example.com", "https://www.tracker.example.com"] def test_whitespace_around_commas_is_stripped(): hosts, origins = derive_hosts_and_origins( "https://a.example.com , https://b.example.com" ) assert hosts == ["a.example.com", "b.example.com"] assert origins == ["https://a.example.com", "https://b.example.com"] def test_url_with_port_is_preserved_in_origin(): hosts, origins = derive_hosts_and_origins("http://localhost:8000") assert hosts == ["localhost"] assert origins == ["http://localhost:8000"] # --- Django integration: derived values are accepted by Django internals ----- @pytest.mark.parametrize( "app_url,request_host", [ ("https://tracker.example.com", "tracker.example.com"), ( "https://tracker.example.com,https://www.tracker.example.com", "www.tracker.example.com", ), ("http://localhost:8000", "localhost"), ], ) def test_derived_hosts_accepted_by_django(app_url, request_host): hosts, _ = derive_hosts_and_origins(app_url) factory = RequestFactory() with override_settings(ALLOWED_HOSTS=hosts): request = factory.get("/", HTTP_HOST=request_host) assert request.get_host() == request_host def test_host_not_in_derived_list_is_rejected(): hosts, _ = derive_hosts_and_origins("https://tracker.example.com") factory = RequestFactory() with override_settings(ALLOWED_HOSTS=hosts): request = factory.get("/", HTTP_HOST="evil.example.com") with pytest.raises(DisallowedHost): request.get_host() def test_derived_origins_accepted_by_csrf_middleware(): _, origins = derive_hosts_and_origins( "https://tracker.example.com,https://other.example.com" ) factory = RequestFactory() middleware = CsrfViewMiddleware(lambda request: None) with override_settings(CSRF_TRUSTED_ORIGINS=origins): for origin in origins: request = factory.post("/", HTTP_ORIGIN=origin) request.META["HTTP_REFERER"] = origin + "/" # _check_token is not called here; _is_secure_referer_ok / origin # matching is what we want — process_view returns None when trusted. assert middleware.process_request(request) is None