Created IAC reverse generator
This commit is contained in:
437
tests/unit/test_cli.py
Normal file
437
tests/unit/test_cli.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user