@@ -244,6 +244,30 @@ def test_incompatible_provider_scheme_raises(self, tmp_path):
244244 with pytest .raises (ValueError , match = "does not support" ):
245245 load_auth_config (cfg )
246246
247+ def test_dangerous_wildcard_host_raises (self , tmp_path ):
248+ cfg = tmp_path / "auth.json"
249+ cfg .write_text (json .dumps ({
250+ "providers" : [{"hosts" : ["*github.com" ], "provider" : "github" , "auth" : "bearer" , "token_env" : "X" }]
251+ }))
252+ with pytest .raises (ValueError , match = "invalid host pattern" ):
253+ load_auth_config (cfg )
254+
255+ def test_multi_wildcard_host_raises (self , tmp_path ):
256+ cfg = tmp_path / "auth.json"
257+ cfg .write_text (json .dumps ({
258+ "providers" : [{"hosts" : ["*.*.example.com" ], "provider" : "github" , "auth" : "bearer" , "token_env" : "X" }]
259+ }))
260+ with pytest .raises (ValueError , match = "invalid host pattern" ):
261+ load_auth_config (cfg )
262+
263+ def test_valid_star_dot_host_accepted (self , tmp_path ):
264+ cfg = tmp_path / "auth.json"
265+ cfg .write_text (json .dumps ({
266+ "providers" : [{"hosts" : ["*.visualstudio.com" ], "provider" : "azure-devops" , "auth" : "basic-pat" , "token_env" : "X" }]
267+ }))
268+ entries = load_auth_config (cfg )
269+ assert entries [0 ].hosts == ("*.visualstudio.com" ,)
270+
247271 @pytest .mark .skipif (os .name == "nt" , reason = "POSIX permission bits not supported on Windows" )
248272 def test_world_readable_warns (self , tmp_path ):
249273 import stat
@@ -658,6 +682,71 @@ def test_timeout_propagates(self, monkeypatch):
658682 open_url ("https://example.com/file" )
659683
660684
685+ # ---------------------------------------------------------------------------
686+ # _load_config caching
687+ # ---------------------------------------------------------------------------
688+
689+
690+ class TestLoadConfigCaching :
691+ def test_config_cached_after_first_load (self , monkeypatch ):
692+ """_load_config() should call load_auth_config only once per process."""
693+ from unittest .mock import patch
694+ from specify_cli .authentication import http as _mod
695+ from specify_cli .authentication .config import AuthConfigEntry
696+ # Allow the real load path (no override)
697+ monkeypatch .setattr (_mod , "_config_override" , None )
698+ monkeypatch .setattr (_mod , "_config_cache" , None )
699+
700+ entry = _github_entry ()
701+ call_count = 0
702+
703+ def fake_load (path = None ):
704+ nonlocal call_count
705+ call_count += 1
706+ return [entry ]
707+
708+ with patch .object (_mod , "load_auth_config" , side_effect = fake_load ):
709+ _mod ._load_config ()
710+ _mod ._load_config ()
711+ _mod ._load_config ()
712+
713+ assert call_count == 1
714+
715+ def test_cache_bypassed_by_override (self , monkeypatch ):
716+ """When _config_override is set, the cache is ignored entirely."""
717+ from specify_cli .authentication import http as _mod
718+ sentinel : list = [object ()] # type: ignore[list-item]
719+ monkeypatch .setattr (_mod , "_config_override" , sentinel )
720+ monkeypatch .setattr (_mod , "_config_cache" , None )
721+
722+ result = _mod ._load_config ()
723+ assert result is sentinel
724+ # Cache must not have been populated when override is active
725+ assert _mod ._config_cache is None
726+
727+ def test_failed_load_warns_once_and_caches_empty (self , monkeypatch ):
728+ """A bad auth.json emits exactly one warning and subsequent calls use cache."""
729+ from unittest .mock import patch
730+ from specify_cli .authentication import http as _mod
731+ import warnings as _warnings
732+ monkeypatch .setattr (_mod , "_config_override" , None )
733+ monkeypatch .setattr (_mod , "_config_cache" , None )
734+
735+ def fail_load (path = None ):
736+ raise ValueError ("bad config" )
737+
738+ with patch .object (_mod , "load_auth_config" , side_effect = fail_load ):
739+ with _warnings .catch_warnings (record = True ) as w :
740+ _warnings .simplefilter ("always" )
741+ _mod ._load_config ()
742+ _mod ._load_config ()
743+ _mod ._load_config ()
744+
745+ user_warnings = [x for x in w if issubclass (x .category , UserWarning )]
746+ assert len (user_warnings ) == 1 , "Expected exactly one warning"
747+ assert _mod ._config_cache == []
748+
749+
661750# ---------------------------------------------------------------------------
662751# Redirect stripping
663752# ---------------------------------------------------------------------------
0 commit comments