Files
SnarfCode/tests/unit/test_cli.py
2026-05-22 00:19:30 -04:00

438 lines
15 KiB
Python

"""Unit tests for the CLI entry point.
Tests command registration, help text, and basic invocation with mocked dependencies.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path
import pytest
from click.testing import CliRunner
from iac_reverse.cli.cli import cli, _load_scan_profile
@pytest.fixture
def runner():
"""Create a Click CliRunner for testing."""
return CliRunner()
class TestCommandRegistration:
"""Test that all commands are registered on the CLI group."""
def test_scan_command_registered(self, runner):
result = runner.invoke(cli, ["scan", "--help"])
assert result.exit_code == 0
assert "Scan infrastructure" in result.output
def test_generate_command_registered(self, runner):
result = runner.invoke(cli, ["generate", "--help"])
assert result.exit_code == 0
assert "Run full pipeline" in result.output
def test_diff_command_registered(self, runner):
result = runner.invoke(cli, ["diff", "--help"])
assert result.exit_code == 0
assert "incremental scan" in result.output
def test_validate_command_registered(self, runner):
result = runner.invoke(cli, ["validate", "--help"])
assert result.exit_code == 0
assert "Validate existing Terraform output" in result.output
def test_login_command_registered(self, runner):
result = runner.invoke(cli, ["login", "--help"])
assert result.exit_code == 0
assert "Authenticate with Authentik SSO" in result.output
def test_all_commands_in_group(self):
command_names = list(cli.commands.keys())
assert "scan" in command_names
assert "generate" in command_names
assert "diff" in command_names
assert "validate" in command_names
assert "login" in command_names
class TestHelpText:
"""Test that help text is available and informative."""
def test_main_help(self, runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "IaC Reverse Engineering Tool" in result.output
assert "scan" in result.output
assert "generate" in result.output
assert "diff" in result.output
assert "validate" in result.output
assert "login" in result.output
def test_version_option(self, runner):
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "0.1.0" in result.output
def test_scan_help_shows_profile_option(self, runner):
result = runner.invoke(cli, ["scan", "--help"])
assert "--profile" in result.output
def test_generate_help_shows_options(self, runner):
result = runner.invoke(cli, ["generate", "--help"])
assert "--profile" in result.output
assert "--output-dir" in result.output
def test_diff_help_shows_profile_option(self, runner):
result = runner.invoke(cli, ["diff", "--help"])
assert "--profile" in result.output
def test_validate_help_shows_dir_option(self, runner):
result = runner.invoke(cli, ["validate", "--help"])
assert "--dir" in result.output
def test_login_help_shows_options(self, runner):
result = runner.invoke(cli, ["login", "--help"])
assert "--url" in result.output
assert "--client-id" in result.output
assert "--client-secret" in result.output
class TestScanCommand:
"""Test the scan command with mocked dependencies."""
def test_scan_missing_profile_option(self, runner):
result = runner.invoke(cli, ["scan"])
assert result.exit_code != 0
assert "Missing option" in result.output or "required" in result.output.lower()
def test_scan_nonexistent_profile(self, runner):
result = runner.invoke(cli, ["scan", "--profile", "nonexistent.yaml"])
assert result.exit_code != 0
@patch("iac_reverse.cli.cli._create_plugin")
def test_scan_with_valid_profile(self, mock_create_plugin, runner, tmp_path):
"""Test scan command with a valid profile and mocked plugin."""
from iac_reverse.models import ScanResult
# Create a valid profile YAML
profile_file = tmp_path / "profile.yaml"
profile_file.write_text(
"provider: kubernetes\n"
"credentials:\n"
" kubeconfig: /path/to/config\n"
"endpoints:\n"
" - https://k8s.local:6443\n"
)
# Mock the plugin and scanner
mock_plugin = MagicMock()
mock_plugin.list_supported_resource_types.return_value = [
"kubernetes_deployment"
]
mock_plugin.list_endpoints.return_value = ["https://k8s.local:6443"]
mock_plugin.discover_resources.return_value = ScanResult(
resources=[],
warnings=[],
errors=[],
scan_timestamp="2024-01-01T00:00:00Z",
profile_hash="abc123",
)
mock_create_plugin.return_value = mock_plugin
result = runner.invoke(cli, ["scan", "--profile", str(profile_file)])
assert result.exit_code == 0
assert "0 resources discovered" in result.output
class TestGenerateCommand:
"""Test the generate command with mocked dependencies."""
def test_generate_missing_options(self, runner):
result = runner.invoke(cli, ["generate"])
assert result.exit_code != 0
@patch("iac_reverse.validator.validator.Validator.validate")
@patch("iac_reverse.state_builder.state_builder.StateBuilder.build")
@patch("iac_reverse.generator.code_generator.CodeGenerator.generate")
@patch("iac_reverse.resolver.resolver.DependencyResolver.resolve")
@patch("iac_reverse.scanner.scanner.Scanner.scan")
@patch("iac_reverse.cli.cli._create_plugin")
def test_generate_full_pipeline(
self,
mock_create_plugin,
mock_scan,
mock_resolve,
mock_generate,
mock_build,
mock_validate,
runner,
tmp_path,
):
"""Test generate command runs the full pipeline."""
from iac_reverse.models import (
ScanResult,
DependencyGraph,
CodeGenerationResult,
GeneratedFile,
StateFile,
ValidationResult,
)
# Create profile
profile_file = tmp_path / "profile.yaml"
profile_file.write_text(
"provider: kubernetes\n"
"credentials:\n"
" kubeconfig: /path/to/config\n"
)
output_dir = tmp_path / "output"
# Mock plugin
mock_plugin = MagicMock()
mock_create_plugin.return_value = mock_plugin
# Mock scanner.scan
mock_scan.return_value = ScanResult(
resources=[],
warnings=[],
errors=[],
scan_timestamp="2024-01-01T00:00:00Z",
profile_hash="abc123",
)
# Mock resolver.resolve
mock_resolve.return_value = DependencyGraph(
resources=[],
relationships=[],
topological_order=[],
cycles=[],
unresolved_references=[],
)
# Mock generator.generate
mock_generate.return_value = CodeGenerationResult(
resource_files=[
GeneratedFile(filename="test.tf", content="# test", resource_count=1)
],
variables_file=GeneratedFile(filename="variables.tf", content="", resource_count=0),
provider_file=GeneratedFile(filename="providers.tf", content="", resource_count=0),
)
# Mock state builder.build
mock_state_file = MagicMock()
mock_state_file.resources = []
mock_state_file.to_json.return_value = "{}"
mock_build.return_value = mock_state_file
# Mock validator.validate
mock_validate.return_value = ValidationResult(
init_success=True,
validate_success=True,
plan_success=True,
)
result = runner.invoke(
cli, ["generate", "--profile", str(profile_file), "--output-dir", str(output_dir)]
)
assert result.exit_code == 0
assert "Generation complete" in result.output
class TestDiffCommand:
"""Test the diff command with mocked dependencies."""
def test_diff_missing_profile(self, runner):
result = runner.invoke(cli, ["diff"])
assert result.exit_code != 0
@patch("iac_reverse.incremental.change_detector.ChangeDetector.compare")
@patch("iac_reverse.incremental.snapshot_store.SnapshotStore.store_snapshot")
@patch("iac_reverse.incremental.snapshot_store.SnapshotStore.load_previous")
@patch("iac_reverse.scanner.scanner.Scanner.scan")
@patch("iac_reverse.scanner.scanner.Scanner._compute_profile_hash")
@patch("iac_reverse.cli.cli._create_plugin")
def test_diff_first_scan(
self,
mock_create_plugin,
mock_compute_hash,
mock_scan,
mock_load_previous,
mock_store_snapshot,
mock_compare,
runner,
tmp_path,
):
"""Test diff command on first scan (no previous snapshot)."""
from iac_reverse.models import ScanResult, ChangeSummary
profile_file = tmp_path / "profile.yaml"
profile_file.write_text(
"provider: kubernetes\n"
"credentials:\n"
" kubeconfig: /path/to/config\n"
)
mock_plugin = MagicMock()
mock_create_plugin.return_value = mock_plugin
mock_compute_hash.return_value = "abc123"
mock_load_previous.return_value = None
mock_scan.return_value = ScanResult(
resources=[],
warnings=[],
errors=[],
scan_timestamp="2024-01-01T00:00:00Z",
profile_hash="abc123",
)
mock_compare.return_value = ChangeSummary(
added_count=0, removed_count=0, modified_count=0, changes=[]
)
result = runner.invoke(cli, ["diff", "--profile", str(profile_file)])
assert result.exit_code == 0
assert "Change Summary" in result.output
class TestValidateCommand:
"""Test the validate command with mocked dependencies."""
def test_validate_missing_dir(self, runner):
result = runner.invoke(cli, ["validate"])
assert result.exit_code != 0
@patch("iac_reverse.validator.validator.Validator.validate")
def test_validate_success(self, mock_validate, runner, tmp_path):
"""Test validate command with successful validation."""
from iac_reverse.models import ValidationResult
mock_validate.return_value = ValidationResult(
init_success=True,
validate_success=True,
plan_success=True,
)
result = runner.invoke(cli, ["validate", "--dir", str(tmp_path)])
assert result.exit_code == 0
assert "All validations passed" in result.output
@patch("iac_reverse.validator.validator.Validator.validate")
def test_validate_failure(self, mock_validate, runner, tmp_path):
"""Test validate command with validation errors."""
from iac_reverse.models import ValidationResult, ValidationError
mock_validate.return_value = ValidationResult(
init_success=True,
validate_success=False,
plan_success=False,
errors=[
ValidationError(file="main.tf", message="syntax error", line=5)
],
)
result = runner.invoke(cli, ["validate", "--dir", str(tmp_path)])
assert result.exit_code == 0
assert "syntax error" in result.output
class TestLoginCommand:
"""Test the login command with mocked dependencies."""
def test_login_missing_options(self, runner):
result = runner.invoke(cli, ["login"])
assert result.exit_code != 0
@patch("iac_reverse.auth.authentik_auth.AuthentikAuthProvider.authenticate_user")
def test_login_success(self, mock_authenticate, runner, tmp_path):
"""Test login command with successful authentication."""
from iac_reverse.auth.authentik_auth import AuthentikSession
mock_authenticate.return_value = AuthentikSession(
access_token="test-token-123",
refresh_token="refresh-456",
user_id="user@example.com",
groups=["admins", "devops"],
)
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(
cli,
[
"login",
"--url", "https://auth.internal.lab",
"--client-id", "iac-reverse",
"--client-secret", "secret123",
],
)
assert result.exit_code == 0
assert "Authenticated as user" in result.output
assert "user@example.com" in result.output
@patch("iac_reverse.auth.authentik_auth.AuthentikAuthProvider.authenticate_user")
def test_login_failure(self, mock_authenticate, runner, tmp_path):
"""Test login command with authentication failure."""
from iac_reverse.auth.authentik_auth import AuthenticationError
mock_authenticate.side_effect = AuthenticationError(
"Invalid credentials"
)
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(
cli,
[
"login",
"--url", "https://auth.internal.lab",
"--client-id", "iac-reverse",
"--client-secret", "bad-secret",
],
)
assert result.exit_code != 0
assert "Authentication failed" in result.output
class TestLoadScanProfile:
"""Test the _load_scan_profile helper function."""
def test_load_valid_profile(self, tmp_path):
profile_file = tmp_path / "profile.yaml"
profile_file.write_text(
"provider: kubernetes\n"
"credentials:\n"
" kubeconfig: /path/to/config\n"
)
profile = _load_scan_profile(str(profile_file))
assert profile.provider == ProviderType.KUBERNETES
assert profile.credentials == {"kubeconfig": "/path/to/config"}
def test_load_nonexistent_profile(self):
with pytest.raises(Exception) as exc_info:
_load_scan_profile("/nonexistent/path.yaml")
assert "not found" in str(exc_info.value).lower() or "Profile not found" in str(exc_info.value)
def test_load_invalid_yaml(self, tmp_path):
profile_file = tmp_path / "bad.yaml"
profile_file.write_text(": : : invalid yaml [[[")
with pytest.raises(Exception):
_load_scan_profile(str(profile_file))
def test_load_unknown_provider(self, tmp_path):
profile_file = tmp_path / "profile.yaml"
profile_file.write_text(
"provider: unknown_provider\n"
"credentials:\n"
" key: value\n"
)
with pytest.raises(Exception) as exc_info:
_load_scan_profile(str(profile_file))
assert "Unknown provider" in str(exc_info.value)
# Import for type reference in tests
from iac_reverse.models import ProviderType