/ tests / agent / test_anthropic_keychain.py
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