Files
Aperant/tests/test_auth.py
T
Andy 4b74092879 Fix API 401 - Token Decryption Before SDK Initialization (#1283)
* 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>
2026-01-18 14:13:43 +01:00

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")