Files
Aperant/tests/test_platform.py
T
Andy ae4e48e8bf Simplified Testing Strategy for Regression Prevention (#1379)
* auto-claude: subtask-1-1 - Add CLI detection tests for Claude/Node/Python acr

Add comprehensive CLI detection tests for cross-platform support:

- TestClaudeDetectionPathsStructured: Tests for structured Claude CLI paths
  - Windows returns .exe paths in platform key
  - Unix returns Homebrew paths and non-.exe paths
  - NVM versions directory path validation

- TestFindExecutableCli: Tests for find_executable() across platforms
  - Windows checks .exe/.cmd/.bat extensions
  - Unix uses shutil.which first
  - macOS searches Homebrew directories
  - Linux searches standard Unix paths
  - Returns None when not found
  - Supports additional_paths parameter

- TestNodeCliDetection: Node.js CLI detection via which
  - Windows, macOS, and Linux detection tests

- TestPythonCliDetection: Python CLI detection patterns
  - Windows prefers py launcher with fallbacks
  - Unix prefers python3

- TestClaudeCliDetectionCrossPlatform: Claude CLI detection per platform
  - Windows includes AppData and Program Files with .exe/.cmd
  - macOS includes Homebrew paths
  - Linux uses standard Unix locations without Homebrew

Also enhanced existing TestClaudeDetectionPaths with:
  - macOS-specific Homebrew path detection
  - Linux-specific path validation (no Homebrew)

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

* auto-claude: subtask-1-2 - Add path handling edge case tests

Added comprehensive path handling tests to test_platform.py:
- Path separator edge cases (Windows semicolon vs Unix colon)
- Path traversal attack prevention (Unix and Windows variants)
- Shell metacharacter injection tests (pipes, semicolons, backticks, etc.)
- Windows environment variable expansion rejection
- Newline injection prevention
- Special path edge cases (empty, whitespace, long paths)
- Executable extension handling edge cases

Total: 50 new path-related tests added, all 105 platform tests passing.

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

* auto-claude: subtask-1-3 - Add token decryption tests for all platforms in ba

Added comprehensive token decryption tests covering:
- Platform routing tests (macOS, Linux, Windows)
- macOS-specific tests (CLI not found, NotImplementedError)
- Linux-specific tests (secretstorage missing, NotImplementedError)
- Windows-specific tests (NotImplementedError)
- Error handling tests (invalid type, empty data, invalid chars,
  FileNotFoundError, PermissionError, timeout, generic errors)
- Keychain integration tests (encrypted token decryption,
  plaintext passthrough, env var precedence)

Total of 25 new token decryption tests added across 6 test classes.

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

* auto-claude: subtask-1-4 - Add frontend platform tests for npm/npx commands, shell config, and binary directory detection

- Added comprehensive npm/npx command tests for all platforms (Windows, macOS, Linux)
- Added consistency test for npm/npx commands across multiple calls
- Expanded shell configuration tests with property validation and platform-specific behavior
- Added requiresShell tests for .cmd, .bat, .ps1 files and case-insensitive extension handling
- Added comprehensive binary directory tests including structure validation
- Added tests for user/system directory arrays on all platforms
- Added tests for Windows-specific npm global and System32 directories
- Added tests for Linux /usr/local/bin directory
- Added validation test ensuring all directory paths are non-empty strings

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

* auto-claude: subtask-2-1 - Create test_agent_flow.py with planner to coder tr

Added comprehensive test suite for agent flow integration covering:
- Planner to coder transition tests (TestPlannerToCoderTransition)
- Post-session processing tests (TestPostSessionProcessing)
- Subtask state transition tests (TestSubtaskStateTransitions)
- Handoff data preservation tests (TestHandoffDataPreservation)
- Planner output validation tests (TestPlannerOutputValidation)

17 tests total verifying:
- first_run flag indicates planner mode correctly
- Transition from planning to coding phase
- Planner completion enables coder session
- Subtask info preserved during transition
- Post-session processing for completed/in_progress/pending subtasks
- Finding subtasks and phases in implementation plan
- Build completion detection
- Recovery hints and commit tracking

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

* auto-claude: subtask-2-2 - Add subtask completion detection tests to test_agent_flow

Added TestSubtaskCompletionDetection class with 16 tests covering:
- Basic count_subtasks functionality
- count_subtasks_detailed with all status types
- is_build_complete edge cases (empty, in_progress, failed)
- Progress percentage calculation
- Status transition detection (pending→in_progress→completed)
- Multiple subtask completion sequences
- Multi-phase plan completion detection
- get_next_subtask behavior after completions

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

* auto-claude: subtask-2-3 - Add QA loop tests for fixer interaction and verdict handling

Added 6 test classes with 19 tests covering:
- TestQALoopStateTransitions: QA run conditions based on build state
- TestQAFixerInteraction: Fixer should_run logic and fixes_applied state
- TestQAVerdictHandling: Approved/rejected verdicts and iteration tracking
- TestQALoopWorkflow: Full workflow tests (approve first try, with rejection)
- TestQASignoffDataStructure: Data structure validation for signoff

All tests follow patterns from test_qa_loop.py reference file.

* auto-claude: subtask-2-4 - Add worktree isolation tests to verify concurrent agents don't conflict

Added TestWorktreeIsolation class with 7 tests:
- test_multiple_worktrees_have_separate_branches
- test_changes_in_one_worktree_dont_affect_another
- test_concurrent_worktree_operations_dont_conflict
- test_worktree_isolation_with_spec_directories
- test_worktree_can_be_removed_without_affecting_others
- test_worktree_merge_isolation
- test_get_or_create_worktree_returns_existing

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

* auto-claude: subtask-3-1 - Expand test_recovery.py with session checkpoint an

* auto-claude: subtask-3-2 - Expand test_implementation_plan.py with JSON schema validation tests

Added 26 new tests in TestSchemaValidation class covering:
- Valid schema tests (minimal plan, full plan, all workflow/phase/status types)
- Invalid schema tests (missing fields, wrong types)
- Edge cases (empty plan, legacy field names, round-trip preservation)
- Complex scenarios (nested dependencies, qa_signoff structure)

* auto-claude: subtask-3-3 - Add tests for edge cases in plan state transitions

Add comprehensive test class TestEdgeCaseStateTransitions with tests for:
- BLOCKED status: initialization, transitions, serialization, phase handling
- STUCK scenarios: all phases blocked, unmet dependencies, failed subtasks
- SKIPPED scenarios: empty phases, completed phases, phase chains

Tests cover:
- Blocked chunk state transitions (blocked -> pending -> in_progress -> completed)
- Blocked to failed transitions for unfeasible tasks
- Plan stuck detection when no available work
- Status summary showing BLOCKED state
- Progress tracking including failed subtask counts
- Empty phase completion and skipping behavior
- Phase dependency deadlock detection
- Plan status updates with blocked subtasks
- Retry transition for failed subtasks

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

* auto-claude: subtask-4-1 - Create test_review_verdict.py with verdict mapping

Add comprehensive tests for the PR review verdict mapping system:
- MergeVerdict enum values and conversions
- Severity to verdict mapping (critical/high -> BLOCKED/NEEDS_REVISION)
- Merge conflict handling (conflicts -> BLOCKED)
- Branch status handling (BEHIND -> NEEDS_REVISION)
- CI status impact on verdicts (failing -> BLOCKED, pending -> NEEDS_REVISION)
- Verdict to overall_status mapping (for GitHub review API)
- Blocker generation from findings
- Combined scenario tests with multiple verdict factors
- Constants tests for BRANCH_BEHIND_BLOCKER_MSG/REASONING

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

* auto-claude: subtask-4-2 - Expand test_finding_validation.py with evidence quality and scope filtering tests

Added two new test classes:
- TestEvidenceQuality: 8 tests for validating evidence quality scenarios
  - Actual code snippets, multiline blocks, context around issues
  - Insufficient evidence, hallucinated findings, special characters
  - High-quality security evidence, claim vs reality comparisons

- TestScopeFiltering: 9 tests for filtering findings by various criteria
  - Filter by category (security, quality)
  - Filter by severity level
  - Filter by file path pattern
  - Filter validation results by status and evidence verification
  - Multiple criteria combinations
  - All ReviewCategory enum values

* auto-claude: subtask-4-3 - Add deduplication and severity mapping tests to test_finding_validation.py

* auto-claude: subtask-5-1 - Create E2E smoke test file with project creation f

* auto-claude: subtask-5-2 - Add task creation and execution E2E test

Added 7 comprehensive E2E tests for task form submission and status updates:
- Task creation with implementation plan/subtask loading
- Task lifecycle status progression through all stages
- Task form validation with missing required fields
- Task completion with subtask progress tracking
- Task update with partial data
- Subtask status update during build
- Task deletion flow

Tests follow patterns from task-lifecycle.test.ts integration tests.

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

* auto-claude: subtask-5-3 - Add settings management E2E test

Added comprehensive E2E tests for settings management flow:
- Settings reset to defaults flow
- Settings validation with invalid values
- Partial settings update handling
- Settings migration from older versions
- Settings save failure handling
- Concurrent settings operations
- Theme toggle cycle test (system -> light -> dark -> system)

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

* auto-claude: subtask-6-1 - Run full backend test suite and verify all new tests pass

- Fixed test pollution issue where test_qa_criteria.py module-level mocks
  were affecting test_agent_flow.py tests
- Updated TestQALoopStateTransitions tests to explicitly patch is_build_complete
  at qa.criteria level to use the real implementation
- Installed missing test dependencies (pytest-asyncio, python-dotenv)
- All 1919 backend tests pass (11 skipped, 1 xfailed, 1 xpassed)

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

* auto-claude: subtask-6-2 - Fix flaky test by clearing CLI path env vars

The test `should set GITHUB_CLI_PATH with same precedence as CLAUDE_CLI_PATH`
was failing because it expected the mocked `getToolInfo` to be called, but
the code only calls `getToolInfo` when the env var is NOT already set.

Added `delete process.env.CLAUDE_CLI_PATH` and `delete process.env.GITHUB_CLI_PATH`
to the beforeEach block to ensure tests use the mocked function instead of
picking up env vars from the local machine.

All 2133 frontend tests now pass.

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

* fix(tests): resolve PR review findings for testing strategy

Address all issues from PR review:

HIGH:
- Remove duplicated setup_test_environment in test_agent_flow.py and
  test_recovery.py, replaced with test_env fixture using temp_git_repo
- Fix test_review_verdict.py to call production helper functions instead
  of reimplementing verdict logic inline

MEDIUM:
- Fix whitespace-only CLI path validation in platform/__init__.py
- Replace no-op test_path_with_multiple_consecutive_separators with
  actual assertions
- Replace no-op test_rejects_null_byte_injection with actual null byte
  rejection test (added \x00 to dangerous_patterns)

LOW:
- Remove redundant subprocess import in test_recovery.py
- Add specific TypeScript interfaces for factory functions in smoke.test.ts

Production code changes:
- apps/backend/core/platform/__init__.py: Reject whitespace-only paths,
  add null byte to dangerous patterns
- apps/backend/runners/github/models.py: Add verdict helper functions
  (verdict_from_severity_counts, apply_merge_conflict_override,
  apply_branch_behind_downgrade, apply_ci_status_override,
  verdict_to_github_status)

Note: Pre-existing test_auth.py failure is unrelated to these changes.

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

* fix(tests): resolve CI test failures

- test_auth.py: Fix monkeypatch to use shutil.which instead of
  non-existent core.auth.find_executable. Also fix expected
  exception type (ValueError wraps NotImplementedError).

- smoke.test.ts: Update assertion to expect undefined as third
  argument for getTasks (matches actual API signature with optional
  forceRefresh parameter).

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

* fix(tests): normalize paths in cross-platform tests

On Windows, Path operations convert forward slashes to backslashes
even when mocking Unix paths. Normalize paths before assertion to
ensure tests pass on all platforms.

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

* fix(tests): resolve PR review findings and Windows CI test failure

- Add null byte validation to frontend isSecurePath (security parity with backend)
- Add null byte injection test to frontend platform tests
- Fix cross-platform nvm path test by normalizing path separators
- Update CI status override docstring to accurately describe behavior
- Convert no-op tests to actual assertions (percent sign and UNC path tests)
- Add missing CI status tests for NEEDS_REVISION and BLOCKED verdicts

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

* fix(tests): normalize paths in cross-platform executable detection tests

The macOS and Linux executable detection tests were failing on Windows CI
because os.path.join uses backslashes on Windows even when mocking
is_windows=False. Fixed by normalizing path separators in both the
isfile_side_effect functions and the assertions.

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

* fix(tests): normalize paths in additional_paths test for Windows CI

Same cross-platform fix applied to test_cli_detection_uses_additional_paths
to handle path separator differences when running on Windows.

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

* fix(tests): normalize paths in Claude CLI detection tests for Windows CI

Applied cross-platform path normalization to test_macos_claude_cli_detection_paths
and test_linux_claude_cli_detection_paths to handle path separator differences
when os.path.join runs on Windows.

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

* fix(tests): resolve Windows CI failure and address code quality findings

CI Fix:
- Split test_allows_literal_percent_in_valid_context into platform-specific
  tests: Unix allows single % in paths, Windows rejects them due to stricter
  executable name validation (security feature)

Code Quality (AI Review Findings):
- Frontend isSecurePath: Add whitespace-only string rejection to match backend
- Frontend tests: Add test for empty/whitespace string rejection
- test_agent_flow.py: Remove redundant sys import (already imported at top)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:45:32 +01:00

1075 lines
47 KiB
Python

"""
Platform Module Tests
Tests the platform abstraction layer using mocks to simulate
different operating systems.
"""
import os
import sys
from pathlib import Path
from unittest.mock import patch
# Add backend to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'apps', 'backend'))
from core.platform import (
get_current_os,
is_windows,
is_macos,
is_linux,
is_unix,
get_path_delimiter,
get_executable_extension,
with_executable_extension,
get_binary_directories,
get_homebrew_path,
get_claude_detection_paths,
get_claude_detection_paths_structured,
get_python_commands,
find_executable,
validate_cli_path,
requires_shell,
build_windows_command,
get_env_var,
get_platform_description,
OS
)
# ============================================================================
# Platform Detection Tests
# ============================================================================
class TestPlatformDetection:
"""Tests for platform detection functions."""
@patch('core.platform.platform.system', return_value='Windows')
def test_detects_windows(self, mock_system):
assert get_current_os() == OS.WINDOWS
assert is_windows() is True
assert is_macos() is False
assert is_linux() is False
assert is_unix() is False
@patch('core.platform.platform.system', return_value='Darwin')
def test_detects_macos(self, mock_system):
assert get_current_os() == OS.MACOS
assert is_windows() is False
assert is_macos() is True
assert is_linux() is False
assert is_unix() is True
@patch('core.platform.platform.system', return_value='Linux')
def test_detects_linux(self, mock_system):
assert get_current_os() == OS.LINUX
assert is_windows() is False
assert is_macos() is False
assert is_linux() is True
assert is_unix() is True
# ============================================================================
# Path Configuration Tests
# ============================================================================
class TestPathConfiguration:
"""Tests for path-related configuration."""
@patch('core.platform.is_windows', return_value=True)
def test_windows_path_delimiter(self, mock_is_windows):
assert get_path_delimiter() == ';'
@patch('core.platform.is_windows', return_value=False)
def test_unix_path_delimiter(self, mock_is_windows):
assert get_path_delimiter() == ':'
@patch('core.platform.is_windows', return_value=True)
def test_windows_executable_extension(self, mock_is_windows):
assert get_executable_extension() == '.exe'
@patch('core.platform.is_windows', return_value=False)
def test_unix_executable_extension(self, mock_is_windows):
assert get_executable_extension() == ''
class TestWithExecutableExtension:
"""Tests for adding executable extensions."""
@patch('core.platform.is_windows', return_value=True)
def test_adds_extension_on_windows(self, mock_is_windows):
assert with_executable_extension('claude') == 'claude.exe'
assert with_executable_extension('node') == 'node.exe'
@patch('core.platform.is_windows', return_value=True)
def test_preserves_existing_extension(self, mock_is_windows):
assert with_executable_extension('claude.exe') == 'claude.exe'
assert with_executable_extension('npm.cmd') == 'npm.cmd'
@patch('core.platform.is_windows', return_value=False)
def test_no_extension_on_unix(self, mock_is_windows):
assert with_executable_extension('claude') == 'claude'
assert with_executable_extension('node') == 'node'
# ============================================================================
# Binary Directories Tests
# ============================================================================
class TestBinaryDirectories:
"""Tests for binary directory detection."""
@patch('core.platform.is_windows', return_value=True)
@patch('pathlib.Path.home', return_value=Path('/home/user'))
@patch.dict(os.environ, {'ProgramFiles': 'C:\\Program Files'})
def test_windows_binary_directories(self, mock_home, mock_is_windows):
dirs = get_binary_directories()
assert 'user' in dirs
assert 'system' in dirs
assert any('AppData' in d for d in dirs['user'])
assert any('Program Files' in d for d in dirs['system'])
@patch('core.platform.is_windows', return_value=False)
@patch('core.platform.is_macos', return_value=True)
def test_macos_binary_directories(self, mock_is_macos, mock_is_windows):
dirs = get_binary_directories()
assert '/opt/homebrew/bin' in dirs['system']
assert '/usr/local/bin' in dirs['system']
@patch('core.platform.is_windows', return_value=False)
@patch('core.platform.is_macos', return_value=False)
def test_linux_binary_directories(self, mock_is_macos, mock_is_windows):
dirs = get_binary_directories()
assert '/usr/bin' in dirs['system']
assert '/snap/bin' in dirs['system']
# ============================================================================
# Homebrew Path Tests
# ============================================================================
class TestHomebrewPath:
"""Tests for Homebrew path detection."""
@patch('core.platform.is_macos', return_value=False)
def test_returns_null_on_non_macos(self, mock_is_macos):
assert get_homebrew_path() is None
@patch('core.platform.is_macos', return_value=True)
@patch('os.path.exists', return_value=False)
def test_returns_default_on_macos(self, mock_exists, mock_is_macos):
# Should return default Apple Silicon path
result = get_homebrew_path()
assert result in ['/opt/homebrew/bin', '/usr/local/bin']
# ============================================================================
# Tool Detection Tests
# ============================================================================
class TestClaudeDetectionPaths:
"""Tests for Claude CLI path detection."""
@patch('core.platform.is_macos', return_value=False)
@patch('core.platform.is_windows', return_value=True)
@patch('pathlib.Path.home', return_value=Path('/home/user'))
def test_windows_claude_paths(self, mock_home, mock_is_windows, mock_is_macos):
paths = get_claude_detection_paths()
assert any('AppData' in p for p in paths)
assert any('Program Files' in p for p in paths)
assert any(p.endswith('.exe') for p in paths)
@patch('core.platform.is_macos', return_value=False)
@patch('core.platform.is_windows', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/user'))
def test_unix_claude_paths(self, mock_home, mock_is_windows, mock_is_macos):
paths = get_claude_detection_paths()
assert any('.local' in p for p in paths)
assert not any(p.endswith('.exe') for p in paths)
@patch('core.platform.is_macos', return_value=True)
@patch('core.platform.is_windows', return_value=False)
@patch('core.platform.get_homebrew_path', return_value='/opt/homebrew/bin')
@patch('pathlib.Path.home', return_value=Path('/Users/testuser'))
def test_macos_claude_detection_paths_include_homebrew(self, mock_home, mock_brew, mock_is_windows, mock_is_macos):
"""macOS Claude detection should include Homebrew paths."""
paths = get_claude_detection_paths()
# Normalize paths for cross-platform comparison (Windows uses backslashes even for mocked Unix paths)
normalized_paths = [p.replace('\\', '/') for p in paths]
assert any('/opt/homebrew/bin/claude' in p for p in normalized_paths)
assert any('.local' in p for p in normalized_paths)
assert not any(p.endswith('.exe') for p in paths)
@patch('core.platform.is_macos', return_value=False)
@patch('core.platform.is_windows', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/linuxuser'))
def test_linux_claude_detection_paths(self, mock_home, mock_is_windows, mock_is_macos):
"""Linux Claude detection should use standard Unix paths."""
paths = get_claude_detection_paths()
# Normalize paths for cross-platform comparison (Windows uses backslashes even for mocked Unix paths)
normalized_paths = [p.replace('\\', '/') for p in paths]
assert any('.local/bin/claude' in p for p in normalized_paths)
assert any('/home/linuxuser/bin/claude' in p for p in normalized_paths)
# Homebrew path should NOT be in Linux paths (only macOS)
assert not any('/opt/homebrew' in p for p in normalized_paths)
class TestPythonCommands:
"""Tests for Python command variations."""
@patch('core.platform.is_windows', return_value=True)
def test_windows_python_commands(self, mock_is_windows):
commands = get_python_commands()
# Commands are now returned as argument sequences
assert ["py", "-3"] in commands
assert ["python"] in commands
@patch('core.platform.is_windows', return_value=False)
def test_unix_python_commands(self, mock_is_windows):
commands = get_python_commands()
# Commands are now returned as argument sequences
assert commands[0] == ["python3"]
# ============================================================================
# CLI Detection Tests - Cross-Platform
# ============================================================================
class TestClaudeDetectionPathsStructured:
"""Tests for structured Claude CLI path detection."""
@patch('core.platform.is_windows', return_value=True)
@patch('pathlib.Path.home', return_value=Path('/home/user'))
def test_windows_structured_claude_detection(self, mock_home, mock_is_windows):
"""Windows should return .exe paths in platform key."""
result = get_claude_detection_paths_structured()
assert 'homebrew' in result
assert 'platform' in result
assert 'nvm_versions_dir' in result
# Platform paths should include Windows-specific locations
platform_paths = result['platform']
assert any('AppData' in p for p in platform_paths)
assert any('.exe' in p for p in platform_paths)
@patch('core.platform.is_windows', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/user'))
def test_unix_structured_claude_detection(self, mock_home, mock_is_windows):
"""Unix should return non-.exe paths and Homebrew paths."""
result = get_claude_detection_paths_structured()
assert 'homebrew' in result
assert 'platform' in result
assert 'nvm_versions_dir' in result
# Homebrew paths should be present for macOS compatibility
homebrew_paths = result['homebrew']
assert '/opt/homebrew/bin/claude' in homebrew_paths
assert '/usr/local/bin/claude' in homebrew_paths
# Platform paths should not include .exe
platform_paths = result['platform']
assert not any('.exe' in p for p in platform_paths)
@patch('core.platform.is_windows', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
def test_nvm_versions_directory_path(self, mock_home, mock_is_windows):
"""NVM versions directory should be in user home."""
result = get_claude_detection_paths_structured()
nvm_dir = result['nvm_versions_dir']
# Normalize path separators for cross-platform compatibility
nvm_dir_normalized = nvm_dir.replace('\\', '/')
assert '.nvm/versions/node' in nvm_dir_normalized
assert 'testuser' in nvm_dir_normalized
class TestFindExecutableCli:
"""Tests for find_executable function across platforms."""
@patch('core.platform.is_windows', return_value=True)
@patch('shutil.which', return_value=None)
@patch('os.path.isdir', return_value=True)
@patch('os.path.isfile')
@patch('pathlib.Path.home', return_value=Path('C:/Users/testuser'))
def test_windows_cli_detection_checks_exe_extensions(
self, mock_home, mock_isfile, mock_isdir, mock_which, mock_is_windows
):
"""Windows should check for .exe, .cmd, .bat extensions."""
# Simulate finding node.exe in system directory
def isfile_side_effect(path):
return 'node.exe' in path and 'Program Files' in path
mock_isfile.side_effect = isfile_side_effect
result = find_executable('node')
# Should have tried to find with extension
assert mock_isfile.called
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value='/usr/bin/node')
def test_unix_cli_detection_uses_which(self, mock_which, mock_is_windows):
"""Unix should use shutil.which first."""
result = find_executable('node')
assert result == '/usr/bin/node'
mock_which.assert_called_with('node')
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value=None)
@patch('core.platform.is_macos', return_value=True)
@patch('os.path.isdir', return_value=True)
@patch('os.path.isfile')
@patch('pathlib.Path.home', return_value=Path('/Users/testuser'))
def test_macos_cli_detection_searches_homebrew(
self, mock_home, mock_isfile, mock_isdir, mock_is_macos, mock_which, mock_is_windows
):
"""macOS should search Homebrew directories."""
def isfile_side_effect(path):
# Normalize path separators for cross-platform test execution
normalized = path.replace('\\', '/')
return normalized == '/opt/homebrew/bin/python3'
mock_isfile.side_effect = isfile_side_effect
result = find_executable('python3')
# Should find in Homebrew path (normalize for cross-platform)
assert result is not None
assert result.replace('\\', '/') == '/opt/homebrew/bin/python3'
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value=None)
@patch('core.platform.is_macos', return_value=False)
@patch('os.path.isdir', return_value=True)
@patch('os.path.isfile')
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
def test_linux_cli_detection_searches_standard_paths(
self, mock_home, mock_isfile, mock_isdir, mock_is_macos, mock_which, mock_is_windows
):
"""Linux should search standard Unix paths."""
def isfile_side_effect(path):
# Normalize path separators for cross-platform test execution
normalized = path.replace('\\', '/')
return normalized == '/usr/bin/python3'
mock_isfile.side_effect = isfile_side_effect
result = find_executable('python3')
# Normalize for cross-platform
assert result is not None
assert result.replace('\\', '/') == '/usr/bin/python3'
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value=None)
@patch('core.platform.is_macos', return_value=False)
@patch('os.path.isdir', return_value=False)
@patch('os.path.isfile', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
def test_cli_detection_returns_none_when_not_found(
self, mock_home, mock_isfile, mock_isdir, mock_is_macos, mock_which, mock_is_windows
):
"""Should return None when executable not found anywhere."""
result = find_executable('nonexistent-cli')
assert result is None
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value=None)
@patch('core.platform.is_macos', return_value=False)
@patch('os.path.isdir', return_value=True)
@patch('os.path.isfile')
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
def test_cli_detection_uses_additional_paths(
self, mock_home, mock_isfile, mock_isdir, mock_is_macos, mock_which, mock_is_windows
):
"""Should search in additional_paths when provided."""
def isfile_side_effect(path):
# Normalize path separators for cross-platform test execution
normalized = path.replace('\\', '/')
return normalized == '/custom/path/mycli'
mock_isfile.side_effect = isfile_side_effect
result = find_executable('mycli', additional_paths=['/custom/path'])
# Normalize for cross-platform
assert result is not None
assert result.replace('\\', '/') == '/custom/path/mycli'
class TestNodeCliDetection:
"""Tests for Node.js CLI detection patterns across platforms."""
@patch('core.platform.is_windows', return_value=True)
@patch('shutil.which', return_value='C:\\Program Files\\nodejs\\node.exe')
def test_windows_node_detection_via_which(self, mock_which, mock_is_windows):
"""Windows Node detection should work via PATH."""
result = find_executable('node')
assert result == 'C:\\Program Files\\nodejs\\node.exe'
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value='/usr/local/bin/node')
def test_macos_node_detection_via_which(self, mock_which, mock_is_windows):
"""macOS Node detection should work via PATH."""
result = find_executable('node')
assert result == '/usr/local/bin/node'
@patch('core.platform.is_windows', return_value=False)
@patch('shutil.which', return_value='/usr/bin/node')
def test_linux_node_detection_via_which(self, mock_which, mock_is_windows):
"""Linux Node detection should work via PATH."""
result = find_executable('node')
assert result == '/usr/bin/node'
class TestPythonCliDetection:
"""Tests for Python CLI detection patterns across platforms."""
@patch('core.platform.is_windows', return_value=True)
def test_windows_python_detection_prefers_py_launcher(self, mock_is_windows):
"""Windows should prefer py launcher."""
commands = get_python_commands()
# First command should be py launcher
assert commands[0] == ["py", "-3"]
@patch('core.platform.is_windows', return_value=False)
def test_unix_python_detection_prefers_python3(self, mock_is_windows):
"""Unix should prefer python3."""
commands = get_python_commands()
assert commands[0] == ["python3"]
assert ["python"] in commands
@patch('core.platform.is_windows', return_value=True)
def test_windows_python_detection_includes_fallbacks(self, mock_is_windows):
"""Windows should have fallback commands."""
commands = get_python_commands()
# Should have multiple options
assert len(commands) >= 3
assert ["python3"] in commands
assert ["py"] in commands
class TestClaudeCliDetectionCrossPlatform:
"""Tests for Claude CLI detection specifically across all platforms."""
@patch('core.platform.is_macos', return_value=False)
@patch('core.platform.is_windows', return_value=True)
@patch('pathlib.Path.home', return_value=Path('C:/Users/testuser'))
def test_windows_claude_cli_detection_paths(self, mock_home, mock_is_windows, mock_is_macos):
"""Windows Claude paths should include standard installation locations."""
paths = get_claude_detection_paths()
# Should include AppData location (npm global)
assert any('AppData\\Roaming\\npm\\claude.cmd' in p.replace('/', '\\') for p in paths)
# Should include Program Files
assert any('Program Files' in p for p in paths)
# All Windows paths should use .exe or .cmd
windows_executables = [p for p in paths if 'Program Files' in p or 'AppData' in p]
assert all(p.endswith('.exe') or p.endswith('.cmd') for p in windows_executables if p)
@patch('core.platform.is_macos', return_value=True)
@patch('core.platform.is_windows', return_value=False)
@patch('core.platform.get_homebrew_path', return_value='/opt/homebrew/bin')
@patch('pathlib.Path.home', return_value=Path('/Users/testuser'))
def test_macos_claude_cli_detection_paths(self, mock_home, mock_brew, mock_is_windows, mock_is_macos):
"""macOS Claude paths should include Homebrew."""
paths = get_claude_detection_paths()
# Normalize path separators for cross-platform test execution
normalized_paths = [p.replace('\\', '/') for p in paths]
# Should include Homebrew path
assert '/opt/homebrew/bin/claude' in normalized_paths
# Should include user local bin
assert any('.local/bin/claude' in p for p in normalized_paths)
# No .exe extensions
assert not any(p.endswith('.exe') for p in paths)
@patch('core.platform.is_macos', return_value=False)
@patch('core.platform.is_windows', return_value=False)
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
def test_linux_claude_cli_detection_paths(self, mock_home, mock_is_windows, mock_is_macos):
"""Linux Claude paths should use standard Unix locations."""
paths = get_claude_detection_paths()
# Normalize path separators for cross-platform test execution
normalized_paths = [p.replace('\\', '/') for p in paths]
# Should include local bin
assert any('.local/bin/claude' in p for p in normalized_paths)
# Should include user bin
assert any('/home/testuser/bin/claude' in p for p in normalized_paths)
# No Homebrew paths (only macOS)
assert not any('/opt/homebrew' in p for p in normalized_paths)
# No .exe extensions
assert not any(p.endswith('.exe') for p in paths)
# ============================================================================
# Path Validation Tests
# ============================================================================
class TestPathValidation:
"""Tests for CLI path validation."""
def test_rejects_path_traversal(self):
assert validate_cli_path('../etc/passwd') is False
assert validate_cli_path('..\\Windows\\System32') is False
def test_rejects_empty_path(self):
assert validate_cli_path('') is False
assert validate_cli_path(None) is False
def test_rejects_shell_metacharacters(self):
"""Shell metacharacters should be rejected to prevent command injection."""
assert validate_cli_path('cmd;rm -rf /') is False
assert validate_cli_path('cmd|cat /etc/passwd') is False
assert validate_cli_path('cmd&background') is False
assert validate_cli_path('cmd`whoami`') is False
assert validate_cli_path('cmd$(whoami)') is False
assert validate_cli_path('cmd{test}') is False
assert validate_cli_path('cmd<input') is False
assert validate_cli_path('cmd>output') is False
def test_rejects_windows_env_expansion(self):
"""Windows environment variable expansion should be rejected."""
assert validate_cli_path('%PROGRAMFILES%\\cmd.exe') is False
assert validate_cli_path('%SystemRoot%\\System32\\cmd.exe') is False
def test_rejects_newline_injection(self):
"""Newlines in paths should be rejected to prevent command injection."""
assert validate_cli_path('cmd\n/bin/sh') is False
assert validate_cli_path('cmd\r\n/bin/sh') is False
@patch('core.platform.is_windows', return_value=True)
def test_validates_windows_names(self, mock_is_windows):
assert validate_cli_path('claude.exe') is True
assert validate_cli_path('my-script.cmd') is True
assert validate_cli_path('dangerous;command.exe') is False
@patch('core.platform.os.path.isfile', return_value=True)
@patch('core.platform.is_windows', return_value=False)
def test_allows_unix_paths(self, mock_is_windows, mock_isfile):
assert validate_cli_path('/usr/bin/node') is True
assert validate_cli_path('/opt/homebrew/bin/python3') is True
# ============================================================================
# Shell Execution Tests
# ============================================================================
class TestShellExecution:
"""Tests for shell execution requirements."""
@patch('core.platform.is_windows', return_value=True)
def test_requires_shell_for_cmd_files(self, mock_is_windows):
assert requires_shell('npm.cmd') is True
assert requires_shell('script.bat') is True
assert requires_shell('node.exe') is False
@patch('core.platform.is_windows', return_value=False)
def test_never_requires_shell_on_unix(self, mock_is_windows):
assert requires_shell('npm') is False
assert requires_shell('node') is False
class TestWindowsCommandBuilder:
"""Tests for Windows command array building."""
@patch('core.platform.is_windows', return_value=True)
@patch.dict(os.environ, {'SystemRoot': 'C:\\Windows', 'ComSpec': 'C:\\Windows\\System32\\cmd.exe'})
def test_wraps_cmd_files_in_cmd_exe(self, mock_is_windows):
result = build_windows_command('npm.cmd', ['install', 'package'])
assert result[0].endswith('cmd.exe')
assert '/d' in result
assert '/s' in result
assert '/c' in result
assert any('npm.cmd' in arg for arg in result)
@patch('core.platform.is_windows', return_value=True)
def test_passes_exe_directly(self, mock_is_windows):
result = build_windows_command('node.exe', ['script.js'])
assert result[0] == 'node.exe'
assert result[1] == 'script.js'
@patch('core.platform.is_windows', return_value=False)
def test_unix_command_simple(self, mock_is_windows):
result = build_windows_command('/usr/bin/node', ['script.js'])
assert result == ['/usr/bin/node', 'script.js']
# ============================================================================
# Environment Variable Tests
# ============================================================================
class TestEnvironmentVariables:
"""Tests for environment variable access."""
@patch.dict(os.environ, {'TEST_VAR': 'value'})
@patch('core.platform.is_windows', return_value=False)
def test_gets_env_var_on_unix(self, mock_is_windows):
assert get_env_var('TEST_VAR') == 'value'
assert get_env_var('NONEXISTENT', 'default') == 'default'
@patch('core.platform.is_windows', return_value=True)
@patch.dict(os.environ, {'TEST_VAR': 'value', 'test_var': 'other'})
def test_case_insensitive_on_windows(self, mock_is_windows):
# Windows should be case-insensitive
result = get_env_var('TEST_VAR')
assert result in ['value', 'other']
# ============================================================================
# Platform Description Tests
# ============================================================================
class TestPlatformDescription:
"""Tests for platform description."""
@patch('platform.system', return_value='Windows')
@patch('platform.machine', return_value='AMD64')
def test_windows_description(self, mock_machine, mock_system):
desc = get_platform_description()
assert 'Windows' in desc
assert 'AMD64' in desc
@patch('core.platform.platform.system', return_value='Darwin')
@patch('platform.machine', return_value='arm64')
def test_macos_description(self, mock_machine, mock_system):
desc = get_platform_description()
assert 'macOS' in desc
assert 'arm64' in desc
# ============================================================================
# Path Separator Edge Case Tests
# ============================================================================
class TestPathSeparatorEdgeCases:
"""Tests for path separator handling across platforms."""
@patch('core.platform.is_windows', return_value=True)
def test_windows_path_delimiter_semicolon(self, mock_is_windows):
"""Windows PATH delimiter must be semicolon."""
delimiter = get_path_delimiter()
assert delimiter == ';'
# Verify it's not the Unix colon
assert delimiter != ':'
@patch('core.platform.is_windows', return_value=False)
def test_unix_path_delimiter_colon(self, mock_is_windows):
"""Unix PATH delimiter must be colon."""
delimiter = get_path_delimiter()
assert delimiter == ':'
# Verify it's not the Windows semicolon
assert delimiter != ';'
@patch('core.platform.is_windows', return_value=True)
def test_windows_backslash_paths_validated(self, mock_is_windows):
"""Windows backslash paths with valid executable names should pass validation.
Note: On Unix hosts, os.path.basename doesn't recognize Windows backslash
as separator. We test relative executable names which work cross-platform.
"""
# Relative paths work for testing Windows validation logic
assert validate_cli_path('app.exe') is True
assert validate_cli_path('tool.exe') is True
assert validate_cli_path('my-tool.exe') is True
assert validate_cli_path('tool_v2.exe') is True
@patch('core.platform.is_windows', return_value=True)
@patch('os.path.basename')
@patch('os.path.isabs', return_value=True)
@patch('os.path.isfile', return_value=True)
def test_windows_absolute_paths_with_mocked_basename(self, mock_isfile, mock_isabs, mock_basename, mock_is_windows):
"""Windows absolute paths should validate when basename extraction is mocked.
This test mocks os.path.basename to simulate Windows behavior on Unix hosts.
"""
# Mock basename to return just the executable name (simulating Windows path parsing)
mock_basename.return_value = 'app.exe'
assert validate_cli_path(r'C:\Program Files\app.exe') is True
mock_basename.return_value = 'tool.exe'
assert validate_cli_path(r'C:\Users\test\AppData\Local\bin\tool.exe') is True
@patch('core.platform.is_windows', return_value=False)
@patch('os.path.isfile', return_value=True)
def test_unix_forward_slash_paths_validated(self, mock_isfile, mock_is_windows):
"""Unix forward slash paths should be validated correctly."""
# Standard Unix paths
assert validate_cli_path('/usr/bin/python3') is True
assert validate_cli_path('/home/user/.local/bin/claude') is True
assert validate_cli_path('/opt/homebrew/bin/node') is True
@patch('core.platform.is_windows', return_value=True)
@patch('os.path.isfile', return_value=True)
def test_windows_mixed_separators_handled(self, mock_isfile, mock_is_windows):
"""Windows should handle mixed path separators."""
# Windows can accept forward slashes in many contexts
assert validate_cli_path('C:/Program Files/app.exe') is True
@patch('core.platform.is_windows', return_value=False)
@patch('os.path.isfile', return_value=True)
def test_path_with_multiple_consecutive_separators(self, mock_isfile, mock_is_windows):
"""Multiple consecutive separators are valid - OS normalizes them."""
# These are technically valid paths; the OS normalizes consecutive separators.
# Our validation focuses on security (shell metacharacters, traversal),
# not path normalization.
assert validate_cli_path('/usr//bin//python') is True
assert validate_cli_path('/opt///homebrew/bin/node') is True
# ============================================================================
# Path Traversal Edge Case Tests
# ============================================================================
class TestPathTraversalEdgeCases:
"""Tests for path traversal attack prevention."""
def test_rejects_basic_unix_traversal(self):
"""Basic Unix path traversal should be rejected."""
assert validate_cli_path('../etc/passwd') is False
assert validate_cli_path('../../etc/passwd') is False
assert validate_cli_path('./../../etc/passwd') is False
def test_rejects_basic_windows_traversal(self):
"""Basic Windows path traversal should be rejected."""
assert validate_cli_path('..\\Windows\\System32') is False
assert validate_cli_path('..\\..\\Windows\\System32') is False
assert validate_cli_path('.\\..\\..\\Windows\\System32') is False
def test_rejects_traversal_in_middle_of_path(self):
"""Path traversal in the middle of a path should be rejected."""
assert validate_cli_path('/usr/bin/../../../etc/passwd') is False
assert validate_cli_path('C:\\Program Files\\..\\..\\Windows\\System32\\cmd.exe') is False
def test_rejects_url_encoded_traversal(self):
"""URL-encoded path traversal patterns should be handled."""
# Note: Our validation uses regex, URL encoding would need decoding first
# These may pass validation but would fail on file lookup
# Testing the literal patterns our regex catches
assert validate_cli_path('../etc/passwd') is False
def test_rejects_null_byte_injection(self):
"""Null byte injection attempts should be rejected."""
# Null bytes can be used for path truncation attacks where
# "malware.exe\x00.txt" might bypass extension checks.
# Our validation explicitly rejects null bytes.
assert validate_cli_path('app\x00.exe') is False
assert validate_cli_path('/usr/bin/python\x00') is False
assert validate_cli_path('malware.exe\x00.txt') is False
def test_allows_paths_containing_dots(self):
"""Legitimate paths with dots should be allowed."""
# Single dot is fine
assert validate_cli_path('my.app.exe') is True
# Dotfiles are common on Unix
assert validate_cli_path('.local') is True
assert validate_cli_path('.config') is True
@patch('core.platform.is_windows', return_value=True)
@patch('os.path.isfile', return_value=True)
def test_allows_legitimate_dotted_paths_windows(self, mock_isfile, mock_is_windows):
"""Windows paths with legitimate dots should be allowed."""
assert validate_cli_path('my.application.exe') is True
assert validate_cli_path('tool.v2.exe') is True
@patch('core.platform.is_windows', return_value=False)
@patch('os.path.isfile', return_value=True)
def test_allows_legitimate_dotted_paths_unix(self, mock_isfile, mock_is_windows):
"""Unix paths with legitimate dots should be allowed."""
assert validate_cli_path('/usr/local/bin/python3.11') is True
assert validate_cli_path('/home/user/.local/bin/claude') is True
# ============================================================================
# Shell Metacharacter Validation Edge Cases
# ============================================================================
class TestShellMetacharacterEdgeCases:
"""Tests for shell metacharacter injection prevention."""
def test_rejects_semicolon_command_chaining(self):
"""Semicolon command chaining should be rejected."""
assert validate_cli_path('cmd;rm -rf /') is False
assert validate_cli_path('app.exe;del *.*') is False
assert validate_cli_path('tool; whoami') is False
def test_rejects_pipe_command_chaining(self):
"""Pipe command chaining should be rejected."""
assert validate_cli_path('cmd|cat /etc/passwd') is False
assert validate_cli_path('app.exe|type secrets.txt') is False
assert validate_cli_path('tool | grep password') is False
def test_rejects_ampersand_background_execution(self):
"""Ampersand background execution should be rejected."""
assert validate_cli_path('cmd&background') is False
assert validate_cli_path('malware.exe&') is False
assert validate_cli_path('tool && evil') is False
def test_rejects_backtick_command_substitution(self):
"""Backtick command substitution should be rejected."""
assert validate_cli_path('cmd`whoami`') is False
assert validate_cli_path('app`id`') is False
assert validate_cli_path('`rm -rf /`') is False
def test_rejects_dollar_command_substitution(self):
"""Dollar sign command substitution should be rejected."""
assert validate_cli_path('cmd$(whoami)') is False
assert validate_cli_path('$(cat /etc/passwd)') is False
assert validate_cli_path('tool$HOME') is False
def test_rejects_curly_brace_expansion(self):
"""Curly brace expansion should be rejected."""
assert validate_cli_path('cmd{test}') is False
assert validate_cli_path('{a,b,c}') is False
assert validate_cli_path('tool{1..10}') is False
def test_rejects_redirect_operators(self):
"""Redirect operators should be rejected."""
assert validate_cli_path('cmd<input') is False
assert validate_cli_path('cmd>output') is False
assert validate_cli_path('cmd>>append') is False
assert validate_cli_path('cmd 2>&1') is False
def test_rejects_square_brackets(self):
"""Square brackets (glob patterns) should be rejected."""
assert validate_cli_path('cmd[test]') is False
assert validate_cli_path('file[0-9].txt') is False
def test_rejects_exclamation_mark(self):
"""Exclamation mark (history expansion) should be rejected."""
assert validate_cli_path('cmd!') is False
assert validate_cli_path('!previous') is False
def test_rejects_caret_character(self):
"""Caret character should be rejected."""
assert validate_cli_path('cmd^test') is False
def test_rejects_double_quotes_in_path(self):
"""Double quotes in path should be rejected."""
assert validate_cli_path('cmd"test"') is False
assert validate_cli_path('"quoted"') is False
# ============================================================================
# Windows Environment Variable Expansion Tests
# ============================================================================
class TestWindowsEnvExpansionEdgeCases:
"""Tests for Windows environment variable expansion prevention."""
def test_rejects_percent_env_expansion(self):
"""Percent-sign environment variable expansion should be rejected."""
assert validate_cli_path('%PROGRAMFILES%\\cmd.exe') is False
assert validate_cli_path('%SystemRoot%\\System32\\cmd.exe') is False
assert validate_cli_path('%USERPROFILE%\\malware.exe') is False
assert validate_cli_path('%TEMP%\\evil.bat') is False
def test_rejects_partial_env_expansion(self):
"""Partial environment variable patterns should be rejected."""
assert validate_cli_path('%PATH%') is False
assert validate_cli_path('prefix%VAR%suffix') is False
@patch('core.platform.is_windows', return_value=False)
def test_allows_literal_percent_in_valid_context_unix(self, mock_is_windows):
"""Single percent signs (not env vars) should be allowed on Unix."""
# Our pattern is r"%[^%]+%" which requires %...% format
# Single percent signs that don't form env var patterns are allowed on Unix
assert validate_cli_path('file100%.txt') is True # Single % without VAR pattern
assert validate_cli_path('100%done') is True # Trailing percent
assert validate_cli_path('%file.txt') is True # Leading single percent
@patch('core.platform.is_windows', return_value=True)
def test_rejects_percent_in_executable_name_windows(self, mock_is_windows):
"""Windows rejects percent signs in executable names for security."""
# Windows has stricter executable name validation that rejects %
# even when not forming %VAR% patterns (part of Windows security model)
assert validate_cli_path('file100%.txt') is False
assert validate_cli_path('100%done') is False
assert validate_cli_path('%file.txt') is False
# ============================================================================
# Newline Injection Edge Case Tests
# ============================================================================
class TestNewlineInjectionEdgeCases:
"""Tests for newline injection attack prevention."""
def test_rejects_unix_newline(self):
"""Unix newline (LF) should be rejected."""
assert validate_cli_path('cmd\n/bin/sh') is False
assert validate_cli_path('app\nmalicious') is False
def test_rejects_windows_newline(self):
"""Windows newline (CRLF) should be rejected."""
assert validate_cli_path('cmd\r\n/bin/sh') is False
assert validate_cli_path('app\r\nevil.exe') is False
def test_rejects_carriage_return_only(self):
"""Carriage return alone should be rejected."""
assert validate_cli_path('cmd\revil') is False
def test_rejects_embedded_newlines(self):
"""Newlines embedded in paths should be rejected."""
assert validate_cli_path('/usr/bin/python\n--version') is False
assert validate_cli_path('C:\\app.exe\r\n-malicious') is False
# ============================================================================
# Special Path Edge Cases
# ============================================================================
class TestSpecialPathEdgeCases:
"""Tests for special path handling edge cases."""
def test_rejects_empty_path(self):
"""Empty paths should be rejected."""
assert validate_cli_path('') is False
def test_rejects_none_path(self):
"""None paths should be rejected."""
assert validate_cli_path(None) is False
def test_rejects_whitespace_only_path(self):
"""Whitespace-only paths should be rejected."""
# Whitespace-only paths are explicitly rejected for security
assert validate_cli_path(' ') is False
assert validate_cli_path('\t') is False
assert validate_cli_path('\n') is False # Also rejected by newline pattern
assert validate_cli_path(' \t ') is False
@patch('core.platform.is_windows', return_value=True)
def test_windows_rejects_spaces_in_executable_name(self, mock_is_windows):
"""Windows executable names with spaces should be rejected for security."""
# Spaces in executable NAMES are rejected (security: prevent injection)
assert validate_cli_path('my app.exe') is False
# But hyphens are allowed
assert validate_cli_path('my-tool.exe') is True
@patch('core.platform.is_windows', return_value=True)
def test_windows_validates_executable_names(self, mock_is_windows):
"""Windows executable name validation should work."""
# Valid names
assert validate_cli_path('app.exe') is True
assert validate_cli_path('my-tool.exe') is True
assert validate_cli_path('tool_v2.exe') is True
assert validate_cli_path('app.cmd') is True
# Invalid names (contain shell metacharacters)
assert validate_cli_path('app;evil.exe') is False
assert validate_cli_path('tool|bad.exe') is False
@patch('core.platform.is_windows', return_value=False)
@patch('os.path.isfile', return_value=True)
def test_unix_allows_hyphens_and_underscores(self, mock_isfile, mock_is_windows):
"""Unix paths with hyphens and underscores should be allowed."""
assert validate_cli_path('/usr/bin/python3') is True
assert validate_cli_path('/usr/local/bin/my-tool') is True
assert validate_cli_path('/opt/my_app/bin/run') is True
def test_relative_path_validation(self):
"""Relative paths (without traversal) should be validated."""
# Simple relative paths are allowed
assert validate_cli_path('myapp') is True
assert validate_cli_path('bin/tool') is True
# But traversal is not
assert validate_cli_path('../bin/tool') is False
@patch('core.platform.is_windows', return_value=True)
def test_windows_unc_paths_rejected_for_security(self, mock_is_windows):
"""Windows UNC paths are rejected for security - not needed for CLI validation."""
# UNC paths start with \\ and are intentionally rejected
# This is a security feature, not a bug
assert validate_cli_path('\\\\server\\share\\file.exe') is False
def test_very_long_paths_handled(self):
"""Very long paths should be handled without errors."""
# Create a reasonably long but valid path
long_component = 'a' * 50
long_path = '/'.join([long_component] * 10) + '/app'
# Should not raise an exception
result = validate_cli_path(long_path)
assert isinstance(result, bool)
# ============================================================================
# Path with Executable Extension Edge Cases
# ============================================================================
class TestExecutableExtensionEdgeCases:
"""Tests for executable extension handling edge cases."""
@patch('core.platform.is_windows', return_value=True)
def test_windows_adds_exe_to_bare_name(self, mock_is_windows):
"""Windows should add .exe to bare executable names."""
assert with_executable_extension('python') == 'python.exe'
assert with_executable_extension('node') == 'node.exe'
assert with_executable_extension('claude') == 'claude.exe'
@patch('core.platform.is_windows', return_value=True)
def test_windows_preserves_existing_exe(self, mock_is_windows):
"""Windows should not double-add .exe extension."""
assert with_executable_extension('python.exe') == 'python.exe'
assert with_executable_extension('node.exe') == 'node.exe'
@patch('core.platform.is_windows', return_value=True)
def test_windows_preserves_cmd_extension(self, mock_is_windows):
"""Windows should preserve .cmd extension."""
assert with_executable_extension('npm.cmd') == 'npm.cmd'
assert with_executable_extension('npx.cmd') == 'npx.cmd'
@patch('core.platform.is_windows', return_value=True)
def test_windows_preserves_bat_extension(self, mock_is_windows):
"""Windows should preserve .bat extension."""
assert with_executable_extension('setup.bat') == 'setup.bat'
assert with_executable_extension('run.bat') == 'run.bat'
@patch('core.platform.is_windows', return_value=False)
def test_unix_no_extension_added(self, mock_is_windows):
"""Unix should not add any extension."""
assert with_executable_extension('python') == 'python'
assert with_executable_extension('python3') == 'python3'
assert with_executable_extension('node') == 'node'
@patch('core.platform.is_windows', return_value=False)
def test_unix_preserves_any_extension(self, mock_is_windows):
"""Unix should preserve any existing extension."""
assert with_executable_extension('script.py') == 'script.py'
assert with_executable_extension('app.sh') == 'app.sh'
@patch('core.platform.is_windows', return_value=True)
def test_handles_empty_input(self, mock_is_windows):
"""Empty input should return empty."""
assert with_executable_extension('') == ''
assert with_executable_extension(None) is None
@patch('core.platform.is_windows', return_value=True)
def test_handles_dotted_names_without_extension(self, mock_is_windows):
"""Names with dots but no extension should get .exe."""
# python3.11 has a dot but no recognized extension
result = with_executable_extension('python3.11')
# The function checks os.path.splitext which would see '.11' as extension
# So it won't add .exe
assert result == 'python3.11' # Keeps as-is since it has an extension