test_anthropic_keychain.py
1 """Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support.""" 2 3 import json 4 import platform 5 from unittest.mock import patch, MagicMock 6 7 import pytest 8 9 from agent.anthropic_adapter import ( 10 _read_claude_code_credentials_from_keychain, 11 read_claude_code_credentials, 12 ) 13 14 15 class TestReadClaudeCodeCredentialsFromKeychain: 16 """Bug 4: macOS Keychain support for Claude Code >=2.1.114.""" 17 18 def test_returns_none_on_linux(self): 19 """Keychain reading is Darwin-only; must return None on other platforms.""" 20 with patch("agent.anthropic_adapter.platform.system", return_value="Linux"): 21 assert _read_claude_code_credentials_from_keychain() is None 22 23 def test_returns_none_on_windows(self): 24 with patch("agent.anthropic_adapter.platform.system", return_value="Windows"): 25 assert _read_claude_code_credentials_from_keychain() is None 26 27 def test_returns_none_when_security_command_not_found(self): 28 """OSError from missing security binary must be handled gracefully.""" 29 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 30 patch("agent.anthropic_adapter.subprocess.run", 31 side_effect=OSError("security not found")): 32 assert _read_claude_code_credentials_from_keychain() is None 33 34 def test_returns_none_on_nonzero_exit_code(self): 35 """security returns non-zero when the Keychain entry doesn't exist.""" 36 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 37 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 38 mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") 39 assert _read_claude_code_credentials_from_keychain() is None 40 41 def test_returns_none_for_empty_stdout(self): 42 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 43 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 44 mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") 45 assert _read_claude_code_credentials_from_keychain() is None 46 47 def test_returns_none_for_non_json_payload(self): 48 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 49 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 50 mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="") 51 assert _read_claude_code_credentials_from_keychain() is None 52 53 def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self): 54 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 55 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 56 mock_run.return_value = MagicMock( 57 returncode=0, 58 stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}), 59 stderr="", 60 ) 61 assert _read_claude_code_credentials_from_keychain() is None 62 63 def test_returns_none_when_access_token_is_empty(self): 64 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 65 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 66 mock_run.return_value = MagicMock( 67 returncode=0, 68 stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}), 69 stderr="", 70 ) 71 assert _read_claude_code_credentials_from_keychain() is None 72 73 def test_parses_valid_keychain_entry(self): 74 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 75 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 76 mock_run.return_value = MagicMock( 77 returncode=0, 78 stdout=json.dumps({ 79 "claudeAiOauth": { 80 "accessToken": "kc-access-token-abc", 81 "refreshToken": "kc-refresh-token-xyz", 82 "expiresAt": 9999999999999, 83 } 84 }), 85 stderr="", 86 ) 87 creds = _read_claude_code_credentials_from_keychain() 88 assert creds is not None 89 assert creds["accessToken"] == "kc-access-token-abc" 90 assert creds["refreshToken"] == "kc-refresh-token-xyz" 91 assert creds["expiresAt"] == 9999999999999 92 assert creds["source"] == "macos_keychain" 93 94 95 class TestReadClaudeCodeCredentialsPriority: 96 """Bug 4: Keychain must be checked before the JSON file.""" 97 98 def test_keychain_takes_priority_over_json_file(self, tmp_path, monkeypatch): 99 """When both Keychain and JSON file have credentials, Keychain wins.""" 100 # Set up JSON file with "older" token 101 json_cred_file = tmp_path / ".claude" / ".credentials.json" 102 json_cred_file.parent.mkdir(parents=True) 103 json_cred_file.write_text(json.dumps({ 104 "claudeAiOauth": { 105 "accessToken": "json-token", 106 "refreshToken": "json-refresh", 107 "expiresAt": 9999999999999, 108 } 109 })) 110 monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) 111 112 # Mock Keychain to return a "newer" token 113 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 114 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 115 mock_run.return_value = MagicMock( 116 returncode=0, 117 stdout=json.dumps({ 118 "claudeAiOauth": { 119 "accessToken": "keychain-token", 120 "refreshToken": "keychain-refresh", 121 "expiresAt": 9999999999999, 122 } 123 }), 124 stderr="", 125 ) 126 creds = read_claude_code_credentials() 127 128 # Keychain token should be returned, not JSON file token 129 assert creds is not None 130 assert creds["accessToken"] == "keychain-token" 131 assert creds["source"] == "macos_keychain" 132 133 def test_falls_back_to_json_when_keychain_returns_none(self, tmp_path, monkeypatch): 134 """When Keychain has no entry, JSON file is used as fallback.""" 135 json_cred_file = tmp_path / ".claude" / ".credentials.json" 136 json_cred_file.parent.mkdir(parents=True) 137 json_cred_file.write_text(json.dumps({ 138 "claudeAiOauth": { 139 "accessToken": "json-fallback-token", 140 "refreshToken": "json-refresh", 141 "expiresAt": 9999999999999, 142 } 143 })) 144 monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) 145 146 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 147 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 148 # Simulate Keychain entry not found 149 mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") 150 creds = read_claude_code_credentials() 151 152 assert creds is not None 153 assert creds["accessToken"] == "json-fallback-token" 154 assert creds["source"] == "claude_code_credentials_file" 155 156 def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch): 157 """No credentials anywhere — must return None cleanly.""" 158 monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) 159 160 with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ 161 patch("agent.anthropic_adapter.subprocess.run") as mock_run: 162 mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") 163 creds = read_claude_code_credentials() 164 165 assert creds is None