/ tests / tools / test_credential_pool_env_fallback.py
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()