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() == ""