4b74092879
* auto-claude: subtask-1-1 - Investigate Claude Code CLI token storage format * auto-claude: subtask-1-2 - Trace existing token flow from frontend to backend Documented complete token flow analysis in INVESTIGATION.md: - Frontend (pty-manager.ts): Passes token from environment without decryption - Backend token retrieval (auth.py): * get_auth_token(): Returns token as-is with enc: prefix intact * require_auth_token(): Passes encrypted token through * ensure_claude_code_oauth_token(): Sets encrypted token in environment - SDK client creation (client.py, simple_client.py): * Both set encrypted token in CLAUDE_CODE_OAUTH_TOKEN env var * SDK receives encrypted token → API 401 error - Identified optimal decryption insertion point: get_auth_token() * Single location ensures all downstream functions get decrypted tokens * Backward compatible with plaintext tokens * Consistent across env vars and keychain sources * auto-claude: subtask-2-1 - Add token format detection utility (detect enc: prefix) * auto-claude: subtask-2-2 - Implement cross-platform token decryption function * auto-claude: subtask-2-3 - Integrate decryption into get_auth_token() and require_auth_token() * auto-claude: subtask-2-4 - Add comprehensive error handling for decryption fa * auto-claude: subtask-3-1 - Add pre-SDK-init token validation in create_client * auto-claude: subtask-3-2 - Add same validation to simple_client.py * fix: Address QA issues - add required unit tests and improve documentation (qa-requested) Fixes: - Added 5 unit tests to tests/test_auth.py for token decryption functionality - Created tests/test_client.py with client token validation test - Updated decrypt_token() docstring to document encrypted token limitation - Improved error messages in platform-specific decryption functions - Fixed is_encrypted_token() to handle None input gracefully - Modified get_auth_token() to return encrypted token when decryption fails Verified: - All 44 auth tests pass (39 existing + 5 new) - Client validation test passes - No regressions in existing functionality QA Fix Session: 1 * fix: Address PR review issues - DRY validation, platform abstraction, security PR Review Issues Fixed: HIGH: - Extract duplicated token validation into shared validate_token_not_encrypted() function in auth.py (removes 10-line duplication in client.py/simple_client.py) - Replace all platform.system() calls with core.platform imports (is_macos(), is_windows(), is_linux()) to follow platform abstraction guidelines MEDIUM: - Remove token data from error message in decrypt_token() to prevent credential exposure in logs - Add log.warning() when token decryption fails to improve runtime visibility - Add explicit assertion in test_get_auth_token_decrypts_encrypted_env_token - Add test_create_simple_client_rejects_encrypted_tokens test - Update INVESTIGATION.md to use function names instead of line numbers LOW: - Remove unused imports (os, Path) from test_client.py - Use find_executable() from platform module in _decrypt_token_macos() - Error messages now consistent between client.py and simple_client.py All 46 tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Remove unused shutil import Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Address follow-up review findings - Remove encrypted data length from error message (security hardening) - Fix misleading SDK version message in NotImplementedError handler - Add language specifiers to markdown code blocks in INVESTIGATION.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Address PR review findings for auth token handling - Make decryption failure handling consistent between env vars and keychain (both now return encrypted token for specific error messaging) - Remove unused claude_path variable in _decrypt_token_macos() - Add clarifying comment for mixed base64 encoding acceptance - Add direct unit tests for validate_token_not_encrypted() - Add assertion that decrypt_token() was called in existing test - Add positive test cases for valid token flow in test_client.py - Add happy-path test for decrypt_token success (mocked) Fixes: NEWREV-001, NEWREV-002, NEWREV-003, NEWREV-004, NEWREV-005, NEWREV-006, NEW-004 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Test User <test@example.com>
736 lines
28 KiB
Python
736 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tests for Authentication System
|
|
================================
|
|
|
|
Tests the auth.py module functionality including:
|
|
- Environment variable token resolution
|
|
- System credential store integration (macOS, Windows, Linux)
|
|
- Token source detection
|
|
- Token validation and format checking
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
from core.auth import (
|
|
AUTH_TOKEN_ENV_VARS,
|
|
ensure_claude_code_oauth_token,
|
|
get_auth_token,
|
|
get_auth_token_source,
|
|
get_sdk_env_vars,
|
|
get_token_from_keychain,
|
|
require_auth_token,
|
|
)
|
|
|
|
|
|
class TestEnvVarTokenResolution:
|
|
"""Tests for environment variable token resolution."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_env(self):
|
|
"""Clear auth environment variables before each test."""
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
yield
|
|
# Cleanup after test
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
|
|
def test_claude_oauth_token_from_env(self):
|
|
"""Reads CLAUDE_CODE_OAUTH_TOKEN from environment."""
|
|
test_token = "sk-ant-oat01-test-token"
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token
|
|
|
|
token = get_auth_token()
|
|
assert token == test_token
|
|
|
|
def test_anthropic_auth_token_from_env(self):
|
|
"""Reads ANTHROPIC_AUTH_TOKEN from environment."""
|
|
test_token = "sk-ant-oat01-test-enterprise-token"
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = test_token
|
|
|
|
token = get_auth_token()
|
|
assert token == test_token
|
|
|
|
def test_claude_oauth_takes_precedence(self):
|
|
"""CLAUDE_CODE_OAUTH_TOKEN takes precedence over ANTHROPIC_AUTH_TOKEN."""
|
|
claude_token = "sk-ant-oat01-claude-token"
|
|
anthropic_token = "sk-ant-oat01-anthropic-token"
|
|
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = claude_token
|
|
|
|
token = get_auth_token()
|
|
assert token == claude_token
|
|
|
|
def test_no_token_returns_none(self, mocker):
|
|
"""Returns None when no auth token is configured."""
|
|
# Mock keychain to return None (env vars already cleared by fixture)
|
|
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
|
token = get_auth_token()
|
|
assert token is None
|
|
|
|
def test_token_source_from_env(self):
|
|
"""Identifies environment variable as token source."""
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "CLAUDE_CODE_OAUTH_TOKEN"
|
|
|
|
def test_empty_token_ignored(self):
|
|
"""Empty string tokens are ignored."""
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = ""
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
|
|
|
token = get_auth_token()
|
|
# Should get ANTHROPIC_AUTH_TOKEN since CLAUDE_CODE_OAUTH_TOKEN is empty
|
|
assert token == "sk-ant-oat01-test-token"
|
|
|
|
|
|
class TestMacOSKeychain:
|
|
"""Tests for macOS keychain token retrieval."""
|
|
|
|
def test_macos_keychain_success(self, monkeypatch):
|
|
"""Successfully retrieves token from macOS keychain."""
|
|
test_token = "sk-ant-oat01-macos-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
mock_result = Mock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = credentials
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
|
|
|
token = get_token_from_keychain()
|
|
assert token == test_token
|
|
|
|
def test_macos_keychain_command_failure(self, monkeypatch):
|
|
"""Returns None when security command fails."""
|
|
mock_result = Mock()
|
|
mock_result.returncode = 1
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_macos_keychain_invalid_json(self, monkeypatch):
|
|
"""Returns None when keychain returns invalid JSON."""
|
|
mock_result = Mock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = "invalid json"
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_macos_keychain_invalid_token_format(self, monkeypatch):
|
|
"""Returns None when token doesn't start with sk-ant-oat01-."""
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}})
|
|
|
|
mock_result = Mock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = credentials
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
|
|
class TestWindowsCredentialFiles:
|
|
"""Tests for Windows credential file token retrieval."""
|
|
|
|
def test_windows_credential_file_success(self, monkeypatch, tmp_path):
|
|
"""Successfully retrieves token from Windows credential file."""
|
|
test_token = "sk-ant-oat01-windows-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
# Create a temporary credential file
|
|
cred_file = tmp_path / ".credentials.json"
|
|
cred_file.write_text(credentials)
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
|
)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token == test_token
|
|
|
|
def test_windows_credential_file_not_found(self, monkeypatch):
|
|
"""Returns None when credential file doesn't exist."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(os.path, "exists", lambda x: False)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_windows_credential_file_invalid_json(self, monkeypatch, tmp_path):
|
|
"""Returns None when credential file contains invalid JSON."""
|
|
cred_file = tmp_path / ".credentials.json"
|
|
cred_file.write_text("invalid json")
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
|
)
|
|
monkeypatch.setattr(os.path, "exists", lambda x: str(x).endswith(".json"))
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
|
|
class TestLinuxSecretService:
|
|
"""Tests for Linux Secret Service token retrieval."""
|
|
|
|
def test_linux_secret_service_not_installed(self, monkeypatch):
|
|
"""Returns None when secretstorage is not installed."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", None)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_dbus_not_available(self, monkeypatch):
|
|
"""Returns None when DBus is not available."""
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
|
|
# Make get_default_collection raise exception
|
|
mock_ss.get_default_collection.side_effect = Exception("DBus not available")
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_success(self, monkeypatch):
|
|
"""Successfully retrieves token from Linux secret service."""
|
|
test_token = "sk-ant-oat01-linux-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
# Mock secretstorage
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
# Mock collection
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
mock_collection.unlock.return_value = None
|
|
|
|
# Mock item
|
|
mock_item = MagicMock()
|
|
mock_item.get_label.return_value = "Claude Code-credentials"
|
|
mock_item.get_secret.return_value = credentials
|
|
mock_item.is_locked.return_value = False
|
|
|
|
mock_collection.search_items.return_value = [mock_item]
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token == test_token
|
|
|
|
def test_linux_secret_service_exact_label_match_only(self, monkeypatch):
|
|
"""Only matches exact 'Claude Code-credentials' label."""
|
|
test_token = "sk-ant-oat01-linux-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
|
|
# Mock item with similar but not exact label
|
|
mock_item = MagicMock()
|
|
mock_item.get_label.return_value = (
|
|
"Some-Claude-Code-Thing" # Similar but not exact
|
|
)
|
|
mock_item.get_secret.return_value = credentials
|
|
|
|
mock_collection.search_items.return_value = [mock_item]
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
# Should return None because label doesn't match exactly
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_locked_collection_unlock_fails(self, monkeypatch):
|
|
"""Returns None when collection is locked and unlock fails."""
|
|
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = True
|
|
mock_collection.unlock.side_effect = Exception("Unlock failed")
|
|
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_no_matching_item(self, monkeypatch):
|
|
"""Returns None when no matching credential found."""
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
mock_collection.search_items.return_value = [] # No items found
|
|
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_invalid_json(self, monkeypatch):
|
|
"""Returns None when stored secret contains invalid JSON."""
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
|
|
mock_item = MagicMock()
|
|
mock_item.get_label.return_value = "Claude Code-credentials"
|
|
mock_item.get_secret.return_value = "invalid json"
|
|
|
|
mock_collection.search_items.return_value = [mock_item]
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
def test_linux_secret_service_invalid_token_format(self, monkeypatch):
|
|
"""Returns None when token doesn't start with sk-ant-oat01-."""
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}})
|
|
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
|
|
mock_item = MagicMock()
|
|
mock_item.get_label.return_value = "Claude Code-credentials"
|
|
mock_item.get_secret.return_value = credentials
|
|
|
|
mock_collection.search_items.return_value = [mock_item]
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
token = get_token_from_keychain()
|
|
assert token is None
|
|
|
|
|
|
class TestRequireAuthToken:
|
|
"""Tests for require_auth_token function."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_env(self, mocker):
|
|
"""Clear auth environment variables and mock keychain before each test."""
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
# Mock keychain to return None (tests that need a token will set env var)
|
|
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
|
yield
|
|
# Cleanup after test
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
|
|
def test_require_token_returns_valid_token(self):
|
|
"""Returns token when valid token exists."""
|
|
test_token = "sk-ant-oat01-test-token"
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token
|
|
|
|
token = require_auth_token()
|
|
assert token == test_token
|
|
|
|
def test_require_token_raises_when_missing(self):
|
|
"""Raises ValueError when no token is configured."""
|
|
with pytest.raises(ValueError, match="No OAuth token found"):
|
|
require_auth_token()
|
|
|
|
def test_error_message_includes_macos_instructions(self, monkeypatch):
|
|
"""Error message includes macOS setup instructions."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
require_auth_token()
|
|
|
|
error_msg = str(exc_info.value)
|
|
assert "macOS Keychain" in error_msg
|
|
assert "claude setup-token" in error_msg
|
|
|
|
def test_error_message_includes_windows_instructions(self, monkeypatch):
|
|
"""Error message includes Windows setup instructions."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
require_auth_token()
|
|
|
|
error_msg = str(exc_info.value)
|
|
assert "Windows Credential Manager" in error_msg
|
|
assert "claude setup-token" in error_msg
|
|
|
|
def test_error_message_includes_linux_instructions(self, monkeypatch):
|
|
"""Error message includes Linux setup instructions."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
require_auth_token()
|
|
|
|
error_msg = str(exc_info.value)
|
|
assert "secret service" in error_msg.lower()
|
|
assert "gnome-keyring" in error_msg.lower()
|
|
assert "claude setup-token" in error_msg
|
|
|
|
|
|
class TestEnsureClaudeCodeOAuthToken:
|
|
"""Tests for ensure_claude_code_oauth_token function."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_env(self):
|
|
"""Clear auth environment variables before each test."""
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
|
yield
|
|
# Cleanup after test
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
|
|
|
def test_does_nothing_when_already_set(self):
|
|
"""Doesn't modify env var when CLAUDE_CODE_OAUTH_TOKEN is already set."""
|
|
existing_token = "sk-ant-oat01-existing-token"
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = existing_token
|
|
|
|
ensure_claude_code_oauth_token()
|
|
|
|
assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == existing_token
|
|
|
|
def test_copies_from_anthropic_auth_token(self):
|
|
"""Copies ANTHROPIC_AUTH_TOKEN to CLAUDE_CODE_OAUTH_TOKEN."""
|
|
anthropic_token = "sk-ant-oat01-anthropic-token"
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token
|
|
|
|
ensure_claude_code_oauth_token()
|
|
|
|
assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == anthropic_token
|
|
|
|
def test_does_nothing_when_no_token_available(self, monkeypatch):
|
|
"""Doesn't set env var when no auth token is available."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
# Ensure keychain returns None
|
|
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
|
|
|
ensure_claude_code_oauth_token()
|
|
|
|
assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ
|
|
|
|
|
|
class TestTokenSourceDetection:
|
|
"""Tests for get_auth_token_source function."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_env(self):
|
|
"""Clear auth environment variables before each test."""
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
yield
|
|
# Cleanup after test
|
|
for var in AUTH_TOKEN_ENV_VARS:
|
|
os.environ.pop(var, None)
|
|
|
|
def test_source_env_var_claude_oauth(self):
|
|
"""Identifies CLAUDE_CODE_OAUTH_TOKEN as source."""
|
|
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "CLAUDE_CODE_OAUTH_TOKEN"
|
|
|
|
def test_source_env_var_anthropic_auth(self):
|
|
"""Identifies ANTHROPIC_AUTH_TOKEN as source."""
|
|
os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token"
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "ANTHROPIC_AUTH_TOKEN"
|
|
|
|
def test_source_macos_keychain(self, monkeypatch):
|
|
"""Identifies macOS Keychain as source."""
|
|
test_token = "sk-ant-oat01-macos-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
mock_result = Mock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = credentials
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result))
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "macOS Keychain"
|
|
|
|
def test_source_windows_credential_files(self, monkeypatch, tmp_path):
|
|
"""Identifies Windows Credential Files as source."""
|
|
test_token = "sk-ant-oat01-windows-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
cred_file = tmp_path / ".credentials.json"
|
|
cred_file.write_text(credentials)
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/")
|
|
)
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "Windows Credential Files"
|
|
|
|
def test_source_linux_secret_service(self, monkeypatch):
|
|
"""Identifies Linux Secret Service as source."""
|
|
test_token = "sk-ant-oat01-linux-token"
|
|
credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}})
|
|
|
|
mock_ss = MagicMock()
|
|
mock_ss.exceptions = MagicMock()
|
|
mock_ss.exceptions.SecretServiceNotAvailableException = Exception
|
|
mock_ss.exceptions.SecretStorageException = Exception
|
|
|
|
mock_collection = MagicMock()
|
|
mock_collection.is_locked.return_value = False
|
|
|
|
mock_item = MagicMock()
|
|
mock_item.get_label.return_value = "Claude Code-credentials"
|
|
mock_item.get_secret.return_value = credentials
|
|
|
|
mock_collection.search_items.return_value = [mock_item]
|
|
mock_ss.get_default_collection.return_value = mock_collection
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
|
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
|
|
|
|
source = get_auth_token_source()
|
|
assert source == "Linux Secret Service"
|
|
|
|
def test_source_none_when_not_found(self, mocker):
|
|
"""Returns None when no token source is found."""
|
|
# Mock keychain to return None (env vars already cleared by fixture)
|
|
mocker.patch("core.auth.get_token_from_keychain", return_value=None)
|
|
source = get_auth_token_source()
|
|
assert source is None
|
|
|
|
|
|
class TestSdkEnvVars:
|
|
"""Tests for get_sdk_env_vars function."""
|
|
|
|
def test_returns_non_empty_vars(self, monkeypatch):
|
|
"""Only returns non-empty environment variables."""
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
|
monkeypatch.setenv("ANTHROPIC_MODEL", "") # Empty, should be excluded
|
|
monkeypatch.setenv("DISABLE_TELEMETRY", "1")
|
|
|
|
env = get_sdk_env_vars()
|
|
|
|
assert "ANTHROPIC_BASE_URL" in env
|
|
assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com"
|
|
assert "ANTHROPIC_MODEL" not in env # Empty value excluded
|
|
assert "DISABLE_TELEMETRY" in env
|
|
assert env["DISABLE_TELEMETRY"] == "1"
|
|
|
|
def test_includes_claude_git_bash_on_windows(self, monkeypatch):
|
|
"""Auto-detects git-bash path on Windows."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
"core.auth._find_git_bash_path",
|
|
lambda: "C:\\Program Files\\Git\\bin\\bash.exe",
|
|
)
|
|
|
|
env = get_sdk_env_vars()
|
|
|
|
assert "CLAUDE_CODE_GIT_BASH_PATH" in env
|
|
assert "Git\\bin\\bash.exe" in env["CLAUDE_CODE_GIT_BASH_PATH"]
|
|
|
|
def test_does_not_include_git_bash_on_non_windows(self, monkeypatch):
|
|
"""Doesn't include git-bash path on non-Windows platforms."""
|
|
monkeypatch.setattr(platform, "system", lambda: "Darwin")
|
|
|
|
env = get_sdk_env_vars()
|
|
|
|
assert "CLAUDE_CODE_GIT_BASH_PATH" not in env
|
|
|
|
def test_does_not_overwrite_existing_git_bash_path(self, monkeypatch):
|
|
"""Respects existing CLAUDE_CODE_GIT_BASH_PATH environment variable."""
|
|
existing_path = "/custom/bash.exe"
|
|
monkeypatch.setenv("CLAUDE_CODE_GIT_BASH_PATH", existing_path)
|
|
|
|
monkeypatch.setattr(platform, "system", lambda: "Windows")
|
|
|
|
env = get_sdk_env_vars()
|
|
|
|
assert env["CLAUDE_CODE_GIT_BASH_PATH"] == existing_path
|
|
|
|
|
|
class TestTokenDecryption:
|
|
"""Tests for token decryption functionality."""
|
|
|
|
def test_is_encrypted_token_detects_prefix(self):
|
|
"""Verify is_encrypted_token() detects enc: prefix."""
|
|
from core.auth import is_encrypted_token
|
|
|
|
assert is_encrypted_token("enc:test123")
|
|
assert is_encrypted_token("enc:djEwtxMGISt3tQ")
|
|
assert not is_encrypted_token("sk-ant-oat01-test")
|
|
assert not is_encrypted_token("")
|
|
assert not is_encrypted_token(None)
|
|
|
|
def test_decrypt_token_validates_format(self):
|
|
"""Verify decrypt_token() validates token format."""
|
|
from core.auth import decrypt_token
|
|
|
|
with pytest.raises(ValueError, match="Invalid encrypted token format"):
|
|
decrypt_token("sk-ant-oat01-test")
|
|
|
|
def test_decrypt_token_handles_short_data(self):
|
|
"""Verify decrypt_token() rejects short encrypted data."""
|
|
from core.auth import decrypt_token
|
|
|
|
with pytest.raises(ValueError, match="too short"):
|
|
decrypt_token("enc:abc")
|
|
|
|
def test_get_auth_token_decrypts_encrypted_env_token(self, monkeypatch):
|
|
"""Verify get_auth_token() attempts to decrypt encrypted tokens from env."""
|
|
from unittest.mock import patch
|
|
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789")
|
|
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
|
|
|
with patch("core.auth.decrypt_token") as mock_decrypt:
|
|
# Simulate decryption failure
|
|
mock_decrypt.side_effect = ValueError("Decryption not implemented")
|
|
|
|
from core.auth import get_auth_token
|
|
|
|
result = get_auth_token()
|
|
|
|
# Verify decrypt_token was called with the encrypted token
|
|
mock_decrypt.assert_called_once_with("enc:testtoken123456789")
|
|
# Verify the encrypted token is returned on decryption failure
|
|
assert result == "enc:testtoken123456789"
|
|
|
|
def test_get_auth_token_returns_decrypted_token_on_success(self, monkeypatch):
|
|
"""Verify get_auth_token() returns decrypted token when decryption succeeds."""
|
|
from unittest.mock import patch
|
|
|
|
encrypted_token = "enc:testtoken123456789"
|
|
decrypted_token = "sk-ant-oat01-decrypted-token"
|
|
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token)
|
|
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
|
|
|
with patch("core.auth.decrypt_token") as mock_decrypt:
|
|
mock_decrypt.return_value = decrypted_token
|
|
|
|
from core.auth import get_auth_token
|
|
|
|
result = get_auth_token()
|
|
|
|
# Verify decrypt_token was called
|
|
mock_decrypt.assert_called_once_with(encrypted_token)
|
|
# Verify the decrypted token is returned
|
|
assert result == decrypted_token
|
|
|
|
def test_backward_compatibility_plaintext_tokens(self, monkeypatch):
|
|
"""Verify plaintext tokens continue to work unchanged."""
|
|
token = "sk-ant-oat01-test"
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token)
|
|
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
|
|
|
from core.auth import get_auth_token
|
|
|
|
result = get_auth_token()
|
|
assert result == token
|
|
|
|
|
|
class TestValidateTokenNotEncrypted:
|
|
"""Tests for validate_token_not_encrypted function."""
|
|
|
|
def test_validate_token_not_encrypted_raises_for_encrypted(self):
|
|
"""Verify validate_token_not_encrypted() raises ValueError for encrypted tokens."""
|
|
from core.auth import validate_token_not_encrypted
|
|
|
|
with pytest.raises(ValueError, match="encrypted format"):
|
|
validate_token_not_encrypted("enc:test123456789012")
|
|
|
|
def test_validate_token_not_encrypted_raises_with_helpful_message(self):
|
|
"""Verify validate_token_not_encrypted() provides helpful error message."""
|
|
from core.auth import validate_token_not_encrypted
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validate_token_not_encrypted("enc:test123456789012")
|
|
|
|
error_msg = str(exc_info.value)
|
|
assert "claude setup-token" in error_msg
|
|
assert "CLAUDE_CODE_OAUTH_TOKEN" in error_msg
|
|
assert "plaintext token" in error_msg
|
|
|
|
def test_validate_token_not_encrypted_accepts_plaintext(self):
|
|
"""Verify validate_token_not_encrypted() accepts plaintext tokens without raising."""
|
|
from core.auth import validate_token_not_encrypted
|
|
|
|
# Should not raise for valid plaintext tokens
|
|
validate_token_not_encrypted("sk-ant-oat01-test-token")
|
|
validate_token_not_encrypted("sk-ant-api01-test-token")
|
|
validate_token_not_encrypted("any-other-plaintext-token")
|
|
|
|
def test_validate_token_not_encrypted_accepts_empty_prefix(self):
|
|
"""Verify validate_token_not_encrypted() accepts tokens without enc: prefix."""
|
|
from core.auth import validate_token_not_encrypted
|
|
|
|
# Token that starts with 'enc' but not 'enc:' should be accepted
|
|
validate_token_not_encrypted("encrypted-looking-but-not")
|
|
validate_token_not_encrypted("enctest")
|