"""Unit: host Claude auth extraction.""" from __future__ import annotations import json import subprocess import tempfile import unittest from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch from bot_bottle.contrib.claude.claude_auth import ( claude_auth_path, claude_host_access_token, ) from bot_bottle.log import Die def _cred_json(access_token: str, **extra) -> str: # type: ignore[no-untyped-def] payload: dict = {"claudeAiOauth": {"accessToken": access_token, **extra}} return json.dumps(payload) class TestClaudeHostAccessToken(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.") self.home = Path(self.tmp.name) self.cred_dir = self.home / ".claude" self.cred_dir.mkdir() self.auth_path = self.cred_dir / ".credentials.json" def tearDown(self): self.tmp.cleanup() def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def] self.auth_path.write_text(json.dumps(payload)) def test_auth_path_uses_home_env(self): self.assertEqual( self.auth_path, claude_auth_path({"HOME": str(self.home)}), ) # --- file-based (Linux) --- def test_file_returns_access_token(self): key = "sk-ant-oat01-real-key" self._write({"claudeAiOauth": {"accessToken": key}}) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) def test_file_missing_claude_ai_oauth_dies(self): self._write({"hasCompletedOnboarding": True}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) def test_file_missing_access_token_dies(self): self._write({"claudeAiOauth": {"expiresAt": 2000000000000}}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) def test_file_empty_access_token_dies(self): self._write({"claudeAiOauth": {"accessToken": ""}}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) def test_file_expired_token_dies(self): # expiresAt is milliseconds; 1_000_000 ms is year 1970 self._write({ "claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, }) with self.assertRaises(Die): claude_host_access_token( {"HOME": str(self.home)}, now=datetime(2026, 1, 1, tzinfo=timezone.utc), ) def test_file_future_expiry_is_accepted(self): key = "sk-ant-oat01-y" # 2_000_000_000_000 ms ≈ year 2033 self._write({ "claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000}, }) out = claude_host_access_token( {"HOME": str(self.home)}, now=datetime(2026, 1, 1, tzinfo=timezone.utc), ) self.assertEqual(key, out) def test_file_absent_expiry_is_accepted(self): key = "sk-ant-oat01-z" self._write({"claudeAiOauth": {"accessToken": key}}) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) def test_file_non_json_dies(self): self.auth_path.write_text("not json {{{") with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) def test_file_json_array_root_dies(self): self.auth_path.write_text("[]") with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) def test_file_extra_fields_are_ignored(self): key = "sk-ant-oat01-real" self._write({ "claudeAiOauth": { "accessToken": key, "refreshToken": "sk-ant-ort01-secret", "scopes": ["user:inference"], "expiresAt": 2_000_000_000_000, }, }) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) # --- macOS Keychain fallback --- def _home_without_creds(self) -> Path: """A home dir that has .claude/ but no .credentials.json.""" empty = self.home / "no-creds" (empty / ".claude").mkdir(parents=True) return empty def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock: mock = MagicMock() mock.returncode = returncode mock.stdout = stdout return mock def test_keychain_used_when_file_absent(self): key = "sk-ant-oat01-keychain" home = self._home_without_creds() with patch( "bot_bottle.contrib.claude.claude_auth.subprocess.run", return_value=self._mock_keychain(_cred_json(key)), ), patch( "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", ): out = claude_host_access_token({"HOME": str(home)}) self.assertEqual(key, out) def test_keychain_failure_when_file_absent_dies(self): home = self._home_without_creds() with patch( "bot_bottle.contrib.claude.claude_auth.subprocess.run", return_value=self._mock_keychain("", returncode=44), ), patch( "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", ): with self.assertRaises(Die): claude_host_access_token({"HOME": str(home)}) def test_no_file_no_keychain_on_linux_dies(self): home = self._home_without_creds() with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"): with self.assertRaises(Die): claude_host_access_token({"HOME": str(home)}) def test_keychain_non_json_dies(self): home = self._home_without_creds() with patch( "bot_bottle.contrib.claude.claude_auth.subprocess.run", return_value=self._mock_keychain("not-json"), ), patch( "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", ): with self.assertRaises(Die): claude_host_access_token({"HOME": str(home)}) def test_keychain_security_not_found_dies(self): home = self._home_without_creds() with patch( "bot_bottle.contrib.claude.claude_auth.subprocess.run", side_effect=FileNotFoundError, ), patch( "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", ): with self.assertRaises(Die): claude_host_access_token({"HOME": str(home)}) if __name__ == "__main__": unittest.main()