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