test_credential_pool_env_fallback.py
1 """Tests for credential_pool .env fallback and auth credential_pool lookup. 2 3 Covers the fix from #15914 / PR #15920: 4 - _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ 5 - _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty 6 - env vars take priority over .env file (handled by get_env_value itself) 7 - env vars take priority over credential pool (fallback only kicks in when env is empty) 8 """ 9 10 import os 11 from pathlib import Path 12 from unittest.mock import MagicMock, patch 13 14 import pytest 15 16 17 def _make_pconfig(provider_id="deepseek", env_vars=None): 18 """Create a minimal ProviderConfig for testing. 19 20 Default provider_id is 'deepseek' because it's a real api_key provider 21 in PROVIDER_REGISTRY (needed for _seed_from_env's generic path). 22 """ 23 from hermes_cli.auth import ProviderConfig 24 return ProviderConfig( 25 id=provider_id, 26 name=provider_id.title(), 27 auth_type="api_key", 28 api_key_env_vars=tuple(env_vars or [f"{provider_id.upper()}_API_KEY"]), 29 ) 30 31 32 @pytest.fixture 33 def isolated_hermes_home(tmp_path, monkeypatch): 34 """Point HERMES_HOME at a temp dir and clear known API key env vars. 35 36 Also invalidates any cached get_env_value state by patching Path.home(). 37 """ 38 home = tmp_path / ".hermes" 39 home.mkdir() 40 monkeypatch.setattr(Path, "home", lambda: tmp_path) 41 monkeypatch.setenv("HERMES_HOME", str(home)) 42 43 # Clear all known API key env vars so get_env_value falls through to .env 44 for key in [ 45 "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", 46 "ZAI_API_KEY", "DEEPSEEK_API_KEY", "ANTHROPIC_TOKEN", 47 "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_BASE_URL", 48 ]: 49 monkeypatch.delenv(key, raising=False) 50 51 return home 52 53 54 def _write_env_file(home: Path, **kwargs) -> None: 55 """Write key=value pairs to ~/.hermes/.env.""" 56 lines = [f"{k}={v}" for k, v in kwargs.items()] 57 (home / ".env").write_text("\n".join(lines) + "\n") 58 59 60 class TestCredentialPoolSeedsFromDotEnv: 61 """_seed_from_env must read keys from ~/.hermes/.env, not just os.environ. 62 63 This is the load-bearing behaviour for the fix: when a user adds a key to 64 .env mid-session or via a non-CLI entry point that doesn't run 65 load_hermes_dotenv, the credential pool must still discover it. 66 """ 67 68 def test_deepseek_key_from_dotenv_only(self, isolated_hermes_home): 69 """Key in .env but not os.environ → _seed_from_env adds a pool entry.""" 70 _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-only-12345") 71 assert "DEEPSEEK_API_KEY" not in os.environ 72 73 from agent.credential_pool import _seed_from_env 74 entries = [] 75 changed, active_sources = _seed_from_env("deepseek", entries) 76 77 assert changed is True 78 assert "env:DEEPSEEK_API_KEY" in active_sources 79 assert any( 80 e.access_token == "sk-dotenv-only-12345" 81 and e.source == "env:DEEPSEEK_API_KEY" 82 for e in entries 83 ), f"Expected seeded entry with dotenv key, got: {[(e.source, e.access_token) for e in entries]}" 84 85 def test_openrouter_key_from_dotenv_only(self, isolated_hermes_home): 86 """OpenRouter path has its own branch — verify it also reads .env.""" 87 _write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-or-dotenv-abc") 88 assert "OPENROUTER_API_KEY" not in os.environ 89 90 from agent.credential_pool import _seed_from_env 91 entries = [] 92 changed, active_sources = _seed_from_env("openrouter", entries) 93 94 assert changed is True 95 assert "env:OPENROUTER_API_KEY" in active_sources 96 assert any( 97 e.access_token == "sk-or-dotenv-abc" for e in entries 98 ) 99 100 def test_empty_dotenv_no_entries(self, isolated_hermes_home): 101 """No .env file, no env vars → no entries seeded (and no crash).""" 102 from agent.credential_pool import _seed_from_env 103 entries = [] 104 changed, active_sources = _seed_from_env("deepseek", entries) 105 assert changed is False 106 assert active_sources == set() 107 assert entries == [] 108 109 def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch): 110 """get_env_value checks os.environ first — verify seeding picks that up.""" 111 _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale") 112 monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz") 113 114 from agent.credential_pool import _seed_from_env 115 entries = [] 116 changed, _ = _seed_from_env("deepseek", entries) 117 118 assert changed is True 119 seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"] 120 assert len(seeded) == 1 121 assert seeded[0].access_token == "sk-env-fresh-xyz" 122 123 124 class TestAuthResolvesFromDotEnv: 125 """_resolve_api_key_provider_secret must also read from ~/.hermes/.env.""" 126 127 def test_key_from_dotenv_only(self, isolated_hermes_home): 128 """Key in .env but not os.environ → _resolve returns it with the env var source.""" 129 _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-resolve-789") 130 assert "DEEPSEEK_API_KEY" not in os.environ 131 132 from hermes_cli.auth import _resolve_api_key_provider_secret 133 key, source = _resolve_api_key_provider_secret( 134 provider_id="deepseek", 135 pconfig=_make_pconfig(), 136 ) 137 assert key == "sk-dotenv-resolve-789" 138 assert source == "DEEPSEEK_API_KEY" 139 140 141 class TestAuthCredentialPoolFallback: 142 """_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty.""" 143 144 def test_credential_pool_fallback_structure(self, isolated_hermes_home): 145 """Empty env + empty .env → auth falls back to credential pool.""" 146 mock_entry = MagicMock() 147 mock_entry.access_token = "test-pool-key-12345" 148 mock_entry.runtime_api_key = "" 149 150 mock_pool = MagicMock() 151 mock_pool.has_credentials.return_value = True 152 mock_pool.peek.return_value = mock_entry 153 154 from hermes_cli.auth import _resolve_api_key_provider_secret 155 with patch("agent.credential_pool.load_pool", return_value=mock_pool): 156 key, source = _resolve_api_key_provider_secret( 157 provider_id="deepseek", 158 pconfig=_make_pconfig(), 159 ) 160 assert "test-pool-key-12345" in key 161 assert "credential_pool" in source 162 163 def test_credential_pool_empty_returns_empty(self, isolated_hermes_home): 164 """Empty env + empty .env + empty pool → empty string.""" 165 mock_pool = MagicMock() 166 mock_pool.has_credentials.return_value = False 167 168 from hermes_cli.auth import _resolve_api_key_provider_secret 169 with patch("agent.credential_pool.load_pool", return_value=mock_pool): 170 key, source = _resolve_api_key_provider_secret( 171 provider_id="deepseek", 172 pconfig=_make_pconfig(), 173 ) 174 assert key == "" 175 176 def test_env_var_takes_priority_over_pool(self, isolated_hermes_home, monkeypatch): 177 """os.environ key wins — credential pool is NEVER consulted.""" 178 monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-key-first-abc123") 179 180 mock_pool = MagicMock() 181 mock_pool.has_credentials.return_value = True 182 183 from hermes_cli.auth import _resolve_api_key_provider_secret 184 with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp: 185 key, source = _resolve_api_key_provider_secret( 186 provider_id="deepseek", 187 pconfig=_make_pconfig(), 188 ) 189 assert key == "sk-env-key-first-abc123" 190 assert source == "DEEPSEEK_API_KEY" 191 # Pool should not even have been loaded — env var satisfied the request first 192 mp.assert_not_called() 193 194 def test_dotenv_takes_priority_over_pool(self, isolated_hermes_home): 195 """Key in .env beats credential pool — pool only fires when both env sources are empty.""" 196 _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-priority-xyz") 197 assert "DEEPSEEK_API_KEY" not in os.environ 198 199 mock_pool = MagicMock() 200 mock_pool.has_credentials.return_value = True 201 202 from hermes_cli.auth import _resolve_api_key_provider_secret 203 with patch("agent.credential_pool.load_pool", return_value=mock_pool) as mp: 204 key, source = _resolve_api_key_provider_secret( 205 provider_id="deepseek", 206 pconfig=_make_pconfig(), 207 ) 208 assert key == "sk-dotenv-priority-xyz" 209 assert source == "DEEPSEEK_API_KEY" 210 mp.assert_not_called()