/ tests / hermes_cli / test_copilot_catalog_oauth_fallback.py
test_copilot_catalog_oauth_fallback.py
  1  """Catalog-API-key fallback for the Copilot ``/model`` picker.
  2  
  3  Regression for #16708: when the user's only Copilot credential is a
  4  ``gho_*`` token (typically obtained via device-code login) stored in
  5  ``auth.json`` under ``credential_pool.copilot[]`` — placed there by
  6  ``hermes auth add copilot`` or by ``_seed_from_env`` when the env var
  7  is set in ``~/.hermes/.env`` — the picker was silently dropping back to
  8  a stale hardcoded list because ``_resolve_copilot_catalog_api_key``
  9  only consulted env vars / ``gh auth token`` and never read the
 10  credential pool.
 11  """
 12  
 13  from unittest.mock import patch
 14  
 15  from hermes_cli.models import _resolve_copilot_catalog_api_key
 16  
 17  
 18  class TestCopilotCatalogApiKeyResolution:
 19      def test_env_var_token_wins_over_pool(self):
 20          """Env-resolved token still short-circuits the pool fallback."""
 21          with patch(
 22              "hermes_cli.auth.resolve_api_key_provider_credentials",
 23              return_value={"api_key": "env-token"},
 24          ), patch(
 25              "hermes_cli.auth.read_credential_pool",
 26          ) as mock_pool:
 27              assert _resolve_copilot_catalog_api_key() == "env-token"
 28              mock_pool.assert_not_called()
 29  
 30      def test_falls_back_to_pool_oauth_token(self):
 31          """Empty env → walk credential_pool.copilot[] for an OAuth access_token."""
 32          with patch(
 33              "hermes_cli.auth.resolve_api_key_provider_credentials",
 34              return_value={"api_key": ""},
 35          ), patch(
 36              "hermes_cli.auth.read_credential_pool",
 37              return_value=[{"access_token": "gho_abc123"}],
 38          ), patch(
 39              "hermes_cli.copilot_auth.exchange_copilot_token",
 40              return_value=("tid_exchanged_xyz", 1234567890.0),
 41          ):
 42              assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
 43  
 44      def test_falls_back_when_env_resolution_raises(self):
 45          """Env path raising an exception still falls through to the pool."""
 46          with patch(
 47              "hermes_cli.auth.resolve_api_key_provider_credentials",
 48              side_effect=RuntimeError("auth.json corrupt"),
 49          ), patch(
 50              "hermes_cli.auth.read_credential_pool",
 51              return_value=[{"access_token": "gho_xyz"}],
 52          ), patch(
 53              "hermes_cli.copilot_auth.exchange_copilot_token",
 54              return_value=("tid_exchanged_xyz", 1234567890.0),
 55          ):
 56              assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
 57  
 58      def test_skips_classic_pat_in_pool(self):
 59          """Classic PATs (``ghp_…``) are unsupported by the Copilot API — skip them."""
 60          with patch(
 61              "hermes_cli.auth.resolve_api_key_provider_credentials",
 62              return_value={"api_key": ""},
 63          ), patch(
 64              "hermes_cli.auth.read_credential_pool",
 65              return_value=[{"access_token": "ghp_classic_pat"}],
 66          ), patch(
 67              "hermes_cli.copilot_auth.exchange_copilot_token",
 68          ) as mock_exchange:
 69              assert _resolve_copilot_catalog_api_key() == ""
 70              mock_exchange.assert_not_called()
 71  
 72      def test_skips_invalid_pool_entries_until_first_exchangeable(self):
 73          """Non-dict entries and entries without an ``access_token`` are skipped."""
 74          with patch(
 75              "hermes_cli.auth.resolve_api_key_provider_credentials",
 76              return_value={"api_key": ""},
 77          ), patch(
 78              "hermes_cli.auth.read_credential_pool",
 79              return_value=[
 80                  "not-a-dict",
 81                  {"label": "no-token-here"},
 82                  {"access_token": ""},
 83                  {"access_token": "gho_first_real_token"},
 84                  {"access_token": "gho_should_not_reach"},
 85              ],
 86          ), patch(
 87              "hermes_cli.copilot_auth.exchange_copilot_token",
 88              return_value=("tid_from_first", 1234567890.0),
 89          ) as mock_exchange:
 90              assert _resolve_copilot_catalog_api_key() == "tid_from_first"
 91              mock_exchange.assert_called_once_with("gho_first_real_token")
 92  
 93      def test_skips_pool_entry_that_fails_to_exchange(self):
 94          """If the first entry won't exchange, try the next — an unsupported pool[0]
 95          must not wedge a later valid entry (Copilot review #16868 finding)."""
 96          attempts: list[str] = []
 97  
 98          def fake_exchange(raw_token: str):
 99              attempts.append(raw_token)
100              if raw_token == "gho_unsupported_account":
101                  raise ValueError("Copilot token exchange failed: HTTP 401")
102              return ("tid_from_second", 1234567890.0)
103  
104          with patch(
105              "hermes_cli.auth.resolve_api_key_provider_credentials",
106              return_value={"api_key": ""},
107          ), patch(
108              "hermes_cli.auth.read_credential_pool",
109              return_value=[
110                  {"access_token": "gho_unsupported_account"},
111                  {"access_token": "gho_valid_token"},
112              ],
113          ), patch(
114              "hermes_cli.copilot_auth.exchange_copilot_token",
115              side_effect=fake_exchange,
116          ):
117              assert _resolve_copilot_catalog_api_key() == "tid_from_second"
118              assert attempts == ["gho_unsupported_account", "gho_valid_token"]
119  
120      def test_all_pool_entries_fail_exchange_returns_empty(self):
121          """All exchanges fail → return "" so the caller falls back to curated."""
122          with patch(
123              "hermes_cli.auth.resolve_api_key_provider_credentials",
124              return_value={"api_key": ""},
125          ), patch(
126              "hermes_cli.auth.read_credential_pool",
127              return_value=[
128                  {"access_token": "gho_expired_a"},
129                  {"access_token": "gho_expired_b"},
130              ],
131          ), patch(
132              "hermes_cli.copilot_auth.exchange_copilot_token",
133              side_effect=ValueError("Copilot token exchange failed"),
134          ):
135              assert _resolve_copilot_catalog_api_key() == ""
136  
137      def test_returns_empty_string_when_no_credentials_anywhere(self):
138          """No env, no pool → empty string (caller falls back to curated list)."""
139          with patch(
140              "hermes_cli.auth.resolve_api_key_provider_credentials",
141              return_value={"api_key": ""},
142          ), patch(
143              "hermes_cli.auth.read_credential_pool",
144              return_value=[],
145          ):
146              assert _resolve_copilot_catalog_api_key() == ""
147  
148      def test_pool_failure_returns_empty_string(self):
149          """If the pool read itself raises, swallow and return ""."""
150          with patch(
151              "hermes_cli.auth.resolve_api_key_provider_credentials",
152              return_value={"api_key": ""},
153          ), patch(
154              "hermes_cli.auth.read_credential_pool",
155              side_effect=RuntimeError("auth.json locked"),
156          ):
157              assert _resolve_copilot_catalog_api_key() == ""