438 lines
15 KiB
Python
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
|