Files
Aperant/tests/test_auth.py
T
Andy d9cd300fee auto-claude: 181-add-expand-button-for-long-task-descriptions (#1653)
* auto-claude: subtask-1-1 - Add expandable description container with toggle button

- Add isExpanded and hasOverflow state for expand/collapse functionality
- Add useLayoutEffect to detect content overflow (scrollHeight > clientHeight)
- Apply max-h-[200px] with overflow-hidden when collapsed
- Add gradient overlay at bottom when content is truncated
- Add centered ghost button with ChevronDown/ChevronUp icons
- Add i18n translations for showMore/showLess in en and fr

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove unrelated changes from branch (qa-requested)

Reset files that were not related to the expand button feature back to
their develop branch state:
- .gitignore
- apps/backend/agents/ (base.py, coder.py, planner.py, session.py)
- apps/backend/core/ (client.py, simple_client.py)
- apps/frontend/src/renderer/App.tsx
- apps/frontend/src/renderer/components/AuthStatusIndicator.tsx
- apps/frontend/src/renderer/components/KanbanBoard.tsx
- apps/frontend/src/renderer/stores/task-store.ts
- apps/frontend/src/shared/i18n/locales/*/common.json
- tests/test_auth.py
- tests/test_issue_884_plan_schema.py

The expand button feature implementation remains intact.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve CI failures in Python tests and lint

- Fix test_integration_phase4.py: Register module in sys.modules before
  exec_module to allow dataclass decorator to find module by name
- Fix ruff format issues in parallel_orchestrator_reviewer.py: Break
  long f-string lines for logger.error and RuntimeError calls
- Fix ruff format issues in pydantic_models.py: Combine Field description
  on single line

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review findings - reset expand state, fix test mocks, remove dead code

- Reset isExpanded when switching tasks to prevent stale expanded state leaking between tasks
- Fix all remaining get_token_from_keychain mock signatures to accept _config_dir parameter
- Remove disabled old orchestrator code block in parallel_orchestrator_reviewer.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review critical and medium issues

- Restore missing constants in base.py that coder.py imports
  (MAX_CONCURRENCY_RETRIES, INITIAL_RETRY_DELAY_SECONDS, MAX_RETRY_DELAY_SECONDS)
- Fix test_issue_884_plan_schema.py mock return types to match
  run_agent_session 3-tuple signature (str, str, dict)
- Add accessibility attributes to expand/collapse button in TaskMetadata.tsx
  (aria-expanded, aria-controls, aria-hidden on icons, id on content)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore loadClaudeProfiles() and add trailing newline to .gitignore

- Restore loadClaudeProfiles() call in App.tsx initial load useEffect
  to fix onboarding detection for OAuth-only users
- Add trailing newline to .gitignore per POSIX convention

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>
2026-02-04 14:06:40 +01:00

1125 lines
44 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, monkeypatch):
"""Returns None when no auth token is configured."""
# Mock keychain to return None (env vars already cleared by fixture)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: 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, monkeypatch):
"""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)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: 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 "/login" 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 "/login" 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)
# Linux error message uses /login and mentions .env file as alternative
assert "/login" in error_msg
assert "CLAUDE_CODE_OAUTH_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 _config_dir=None: 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()
# Source can be "macOS Keychain" or "macOS Keychain (profile)" depending on profile settings
assert source is not None and source.startswith("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()
# Source can have "(profile)" suffix depending on profile settings
assert source is not None and source.startswith("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()
# Source can have "(profile)" suffix depending on profile settings
assert source is not None and source.startswith("Linux Secret Service")
def test_source_none_when_not_found(self, monkeypatch):
"""Returns None when no token source is found."""
# Mock keychain to return None (env vars already cleared by fixture)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda _config_dir=None: 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 _config_dir=None: 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 _config_dir=None: 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 _config_dir=None: None)
from core.auth import get_auth_token
result = get_auth_token()
assert result == token
class TestTokenDecryptionPlatformRouting:
"""Tests for decrypt_token() platform-specific routing."""
def test_decrypt_token_routes_to_macos(self, monkeypatch):
"""Verify decrypt_token routes to macOS implementation on Darwin."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_macos") as mock_macos:
mock_macos.side_effect = NotImplementedError("macOS test")
from core.auth import decrypt_token
with pytest.raises(ValueError, match="not yet implemented"):
decrypt_token("enc:validbase64data")
mock_macos.assert_called_once_with("validbase64data")
def test_decrypt_token_routes_to_linux(self, monkeypatch):
"""Verify decrypt_token routes to Linux implementation."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: True)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_linux") as mock_linux:
mock_linux.side_effect = NotImplementedError("Linux test")
from core.auth import decrypt_token
with pytest.raises(ValueError, match="not yet implemented"):
decrypt_token("enc:validbase64data")
mock_linux.assert_called_once_with("validbase64data")
def test_decrypt_token_routes_to_windows(self, monkeypatch):
"""Verify decrypt_token routes to Windows implementation."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: True)
with patch("core.auth._decrypt_token_windows") as mock_windows:
mock_windows.side_effect = NotImplementedError("Windows test")
from core.auth import decrypt_token
with pytest.raises(ValueError, match="not yet implemented"):
decrypt_token("enc:validbase64data")
mock_windows.assert_called_once_with("validbase64data")
def test_decrypt_token_unsupported_platform(self, monkeypatch):
"""Verify decrypt_token raises error on unsupported platform."""
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
from core.auth import decrypt_token
with pytest.raises(ValueError, match="Unsupported platform"):
decrypt_token("enc:validbase64data")
class TestTokenDecryptionMacOS:
"""Tests for macOS-specific token decryption."""
def test_macos_decrypt_no_claude_cli(self, monkeypatch):
"""Verify macOS decryption fails when Claude CLI is not found."""
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
# Mock shutil.which to return None (CLI not found)
monkeypatch.setattr("shutil.which", lambda name: None)
from core.auth import decrypt_token
with pytest.raises(ValueError, match="Claude Code CLI not found"):
decrypt_token("enc:validbase64data")
def test_macos_decrypt_raises_not_implemented(self, monkeypatch):
"""Verify macOS decryption raises ValueError (wrapping NotImplementedError) with helpful message."""
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
# Mock shutil.which to return a path (CLI found)
monkeypatch.setattr("shutil.which", lambda name: "/usr/local/bin/claude")
from core.auth import decrypt_token
# NotImplementedError is wrapped in ValueError at the decrypt_token level
with pytest.raises(ValueError) as exc_info:
decrypt_token("enc:validbase64data")
error_msg = str(exc_info.value)
# Should mention alternatives
assert "setup-token" in error_msg or "plaintext" in error_msg
class TestTokenDecryptionLinux:
"""Tests for Linux-specific token decryption."""
def test_linux_decrypt_no_secretstorage(self, monkeypatch):
"""Verify Linux decryption fails when secretstorage is not installed."""
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: True)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
monkeypatch.setattr("core.auth.secretstorage", None)
from core.auth import decrypt_token
with pytest.raises(ValueError, match="secretstorage"):
decrypt_token("enc:validbase64data")
def test_linux_decrypt_raises_not_implemented(self, monkeypatch):
"""Verify Linux decryption raises NotImplementedError with helpful message."""
mock_ss = MagicMock()
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: True)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
monkeypatch.setattr("core.auth.secretstorage", mock_ss)
from core.auth import decrypt_token
with pytest.raises(ValueError) as exc_info:
decrypt_token("enc:validbase64data")
error_msg = str(exc_info.value)
# Should mention alternatives
assert "setup-token" in error_msg or "plaintext" in error_msg
class TestTokenDecryptionWindows:
"""Tests for Windows-specific token decryption."""
def test_windows_decrypt_raises_not_implemented(self, monkeypatch):
"""Verify Windows decryption raises NotImplementedError with helpful message."""
monkeypatch.setattr("core.auth.is_macos", lambda: False)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: True)
from core.auth import decrypt_token
with pytest.raises(ValueError) as exc_info:
decrypt_token("enc:validbase64data")
error_msg = str(exc_info.value)
# Should mention alternatives
assert "setup-token" in error_msg or "plaintext" in error_msg
class TestTokenDecryptionErrorHandling:
"""Tests for error handling in token decryption."""
def test_decrypt_token_invalid_type(self):
"""Verify decrypt_token rejects non-string input."""
from core.auth import decrypt_token
with pytest.raises(ValueError, match="Invalid token type"):
decrypt_token(12345) # type: ignore
with pytest.raises(ValueError, match="Invalid token type"):
decrypt_token(["enc:test"]) # type: ignore
def test_decrypt_token_empty_after_prefix(self):
"""Verify decrypt_token rejects empty data after prefix."""
from core.auth import decrypt_token
with pytest.raises(ValueError, match="Empty encrypted token data"):
decrypt_token("enc:")
def test_decrypt_token_invalid_characters(self):
"""Verify decrypt_token rejects invalid base64 characters."""
from core.auth import decrypt_token
with pytest.raises(ValueError, match="invalid characters"):
decrypt_token("enc:test!@#$%^&*()")
def test_decrypt_token_valid_base64_characters_accepted(self):
"""Verify decrypt_token accepts standard and URL-safe base64 characters."""
from core.auth import decrypt_token
from unittest.mock import patch
# Standard base64 includes +/=
# URL-safe base64 includes -_
valid_tokens = [
"enc:testABCabc123+/=",
"enc:testABCabc123-_==",
"enc:abcdefghij",
]
# These should pass character validation but fail at platform-specific
# decryption (which raises NotImplementedError)
for token in valid_tokens:
with patch("core.auth.is_macos", return_value=False):
with patch("core.auth.is_linux", return_value=False):
with patch("core.auth.is_windows", return_value=False):
with pytest.raises(ValueError, match="Unsupported platform"):
decrypt_token(token)
def test_decrypt_token_file_not_found_error(self, monkeypatch):
"""Verify decrypt_token handles FileNotFoundError gracefully."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_macos") as mock_macos:
mock_macos.side_effect = FileNotFoundError("Credentials file not found")
from core.auth import decrypt_token
with pytest.raises(ValueError, match="required file not found"):
decrypt_token("enc:validbase64data")
def test_decrypt_token_permission_error(self, monkeypatch):
"""Verify decrypt_token handles PermissionError gracefully."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_macos") as mock_macos:
mock_macos.side_effect = PermissionError("Access denied")
from core.auth import decrypt_token
with pytest.raises(ValueError, match="permission denied"):
decrypt_token("enc:validbase64data")
def test_decrypt_token_timeout_error(self, monkeypatch):
"""Verify decrypt_token handles subprocess timeout gracefully."""
import subprocess
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_macos") as mock_macos:
mock_macos.side_effect = subprocess.TimeoutExpired("cmd", 5)
from core.auth import decrypt_token
with pytest.raises(ValueError, match="timed out"):
decrypt_token("enc:validbase64data")
def test_decrypt_token_generic_error(self, monkeypatch):
"""Verify decrypt_token handles unexpected errors gracefully."""
from unittest.mock import patch
monkeypatch.setattr("core.auth.is_macos", lambda: True)
monkeypatch.setattr("core.auth.is_linux", lambda: False)
monkeypatch.setattr("core.auth.is_windows", lambda: False)
with patch("core.auth._decrypt_token_macos") as mock_macos:
mock_macos.side_effect = RuntimeError("Unexpected error")
from core.auth import decrypt_token
with pytest.raises(ValueError) as exc_info:
decrypt_token("enc:validbase64data")
error_msg = str(exc_info.value)
assert "RuntimeError" in error_msg
assert "setup-token" in error_msg
class TestTokenDecryptionKeychain:
"""Tests for encrypted token handling from keychain sources."""
@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_keychain_encrypted_token_decryption_attempted(self, monkeypatch):
"""Verify encrypted tokens from keychain trigger decryption."""
from unittest.mock import patch
encrypted_token = "enc:keychaintoken1234"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda _config_dir=None: encrypted_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
mock_decrypt.side_effect = ValueError("Decryption failed")
from core.auth import get_auth_token
result = get_auth_token()
mock_decrypt.assert_called_once_with(encrypted_token)
# On failure, encrypted token is returned for client validation
assert result == encrypted_token
def test_keychain_encrypted_token_decryption_success(self, monkeypatch):
"""Verify successful decryption of keychain token."""
from unittest.mock import patch
encrypted_token = "enc:keychaintoken1234"
decrypted_token = "sk-ant-oat01-from-keychain"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda _config_dir=None: encrypted_token
)
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()
mock_decrypt.assert_called_once_with(encrypted_token)
assert result == decrypted_token
def test_plaintext_keychain_token_not_decrypted(self, monkeypatch):
"""Verify plaintext tokens from keychain are not passed to decrypt."""
from unittest.mock import patch
plaintext_token = "sk-ant-oat01-keychain-plaintext"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda _config_dir=None: plaintext_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
from core.auth import get_auth_token
result = get_auth_token()
mock_decrypt.assert_not_called()
assert result == plaintext_token
def test_env_var_takes_precedence_over_keychain(self, monkeypatch):
"""Verify environment variable token takes precedence over keychain."""
env_token = "sk-ant-oat01-from-env"
keychain_token = "sk-ant-oat01-from-keychain"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", env_token)
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda _config_dir=None: keychain_token
)
from core.auth import get_auth_token
result = get_auth_token()
assert result == env_token
def test_encrypted_env_var_precedence_over_plaintext_keychain(self, monkeypatch):
"""Verify encrypted env var is preferred over plaintext keychain token."""
from unittest.mock import patch
encrypted_env = "enc:encryptedfromenv"
decrypted_env = "sk-ant-oat01-decrypted-env"
keychain_token = "sk-ant-oat01-from-keychain"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_env)
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda _config_dir=None: keychain_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
mock_decrypt.return_value = decrypted_env
from core.auth import get_auth_token
result = get_auth_token()
mock_decrypt.assert_called_once_with(encrypted_env)
assert result == decrypted_env
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")