"""Property-based tests for multi-provider merging and filtering. **Validates: Requirements 5.3, 5.4, 6.1, 6.2, 6.4, 6.6, 6.7** Property 18: Multi-provider merge with naming conflict resolution For any two or more resource inventories from different on-premises providers where resource names collide, the merged inventory SHALL contain all resources from all providers, with conflicting names prefixed by the provider identifier, and no resources lost. Property 19: Provider block generation For any resource set spanning N distinct on-premises providers, the generated provider configuration SHALL contain exactly N provider blocks, one per distinct provider. Property 20: Scan profile validation completeness (additional multi-provider scenarios) Already covered in test_scan_profile_validation_prop.py; this adds multi-provider scenarios. Property 21: Filtering correctness For any scan profile with resource type filters, the discovered resources SHALL be a subset where every resource's type is in the filter list. No resource outside the filter criteria shall appear. """ from typing import Callable from hypothesis import given, assume, settings from hypothesis import strategies as st from iac_reverse.models import ( CpuArchitecture, DiscoveredResource, GeneratedFile, PlatformCategory, PROVIDER_PLATFORM_MAP, PROVIDER_SUPPORTED_RESOURCE_TYPES, ProviderType, ScanProfile, ScanProgress, ScanResult, ) from iac_reverse.generator import ProviderBlockGenerator, ResourceMerger from iac_reverse.plugin_base import ProviderPlugin from iac_reverse.scanner.scanner import Scanner # --------------------------------------------------------------------------- # Hypothesis Strategies # --------------------------------------------------------------------------- provider_type_strategy = st.sampled_from(list(ProviderType)) architecture_strategy = st.sampled_from(list(CpuArchitecture)) non_empty_credentials_strategy = st.dictionaries( keys=st.text( min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=("L", "N")), ), values=st.text(min_size=1, max_size=50), min_size=1, max_size=5, ) # Strategy for resource names that could collide across providers resource_name_strategy = st.text( min_size=1, max_size=30, alphabet=st.characters(whitelist_categories=("L", "N", "Pd")), ).filter(lambda s: s.strip()) def discovered_resource_strategy( provider: ProviderType, name: str | None = None, ) -> st.SearchStrategy[DiscoveredResource]: """Generate a DiscoveredResource for a given provider with optional fixed name.""" supported_types = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] platform_category = PROVIDER_PLATFORM_MAP[provider] return st.builds( DiscoveredResource, resource_type=st.sampled_from(supported_types), unique_id=st.uuids().map(str), name=st.just(name) if name else resource_name_strategy, provider=st.just(provider), platform_category=st.just(platform_category), architecture=architecture_strategy, endpoint=st.just("http://localhost:8080"), attributes=st.just({"key": "value"}), raw_references=st.just([]), ) def scan_result_strategy( provider: ProviderType, resources: list[DiscoveredResource] | None = None, ) -> st.SearchStrategy[ScanResult]: """Generate a ScanResult for a given provider.""" if resources is not None: return st.just(ScanResult( resources=resources, warnings=[], errors=[], scan_timestamp="2024-01-01T00:00:00Z", profile_hash="abc123", )) return st.builds( ScanResult, resources=st.lists( discovered_resource_strategy(provider), min_size=1, max_size=5, ), warnings=st.just([]), errors=st.just([]), scan_timestamp=st.just("2024-01-01T00:00:00Z"), profile_hash=st.just("abc123"), ) # --------------------------------------------------------------------------- # Mock Plugin for Filtering Tests # --------------------------------------------------------------------------- class FilteringPlugin(ProviderPlugin): """A plugin that discovers resources only for requested resource types.""" def __init__(self, provider: ProviderType): self._provider = provider self._supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] def authenticate(self, credentials: dict[str, str]) -> None: pass def get_platform_category(self) -> PlatformCategory: return PROVIDER_PLATFORM_MAP[self._provider] def list_endpoints(self) -> list[str]: return ["http://localhost:8080"] def list_supported_resource_types(self) -> list[str]: return self._supported def detect_architecture(self, endpoint: str) -> CpuArchitecture: return CpuArchitecture.AMD64 def discover_resources( self, endpoints: list[str], resource_types: list[str], progress_callback: Callable[[ScanProgress], None], ) -> ScanResult: """Discover exactly one resource per requested resource type.""" resources = [] for i, rt in enumerate(resource_types): resources.append( DiscoveredResource( resource_type=rt, unique_id=f"id-{rt}-{i}", name=f"resource-{rt}-{i}", provider=self._provider, platform_category=PROVIDER_PLATFORM_MAP[self._provider], architecture=CpuArchitecture.AMD64, endpoint="http://localhost:8080", attributes={"key": "value"}, ) ) progress_callback(ScanProgress( current_resource_type=rt, resources_discovered=i + 1, resource_types_completed=i + 1, total_resource_types=len(resource_types), )) return ScanResult( resources=resources, warnings=[], errors=[], scan_timestamp="2024-01-01T00:00:00Z", profile_hash="abc123", ) # --------------------------------------------------------------------------- # Property 18: Multi-provider merge with naming conflict resolution # --------------------------------------------------------------------------- class TestMultiProviderMergeConflictResolution: """Property 18: Multi-provider merge with naming conflict resolution. When resources from different providers share the same name, the merger prefixes with provider identifier. **Validates: Requirements 5.3** """ @given( provider_a=provider_type_strategy, provider_b=provider_type_strategy, shared_name=resource_name_strategy, ) @settings(max_examples=100) def test_conflicting_names_are_prefixed(self, provider_a, provider_b, shared_name): """Resources with the same name from different providers get prefixed.""" assume(provider_a != provider_b) resource_a = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_a][0], unique_id="id-a", name=shared_name, provider=provider_a, platform_category=PROVIDER_PLATFORM_MAP[provider_a], architecture=CpuArchitecture.AMD64, endpoint="http://host-a:8080", attributes={"source": "a"}, ) resource_b = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_b][0], unique_id="id-b", name=shared_name, provider=provider_b, platform_category=PROVIDER_PLATFORM_MAP[provider_b], architecture=CpuArchitecture.AMD64, endpoint="http://host-b:8080", attributes={"source": "b"}, ) scan_result_a = ScanResult( resources=[resource_a], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) scan_result_b = ScanResult( resources=[resource_b], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) merger = ResourceMerger() merged = merger.merge([scan_result_a, scan_result_b]) # Both resources must be present (no loss) assert len(merged) == 2 # Conflicting names must be prefixed with provider identifier merged_names = {r.name for r in merged} expected_name_a = f"{provider_a.value}_{shared_name}" expected_name_b = f"{provider_b.value}_{shared_name}" assert expected_name_a in merged_names, ( f"Expected '{expected_name_a}' in merged names, got: {merged_names}" ) assert expected_name_b in merged_names, ( f"Expected '{expected_name_b}' in merged names, got: {merged_names}" ) @given( provider_a=provider_type_strategy, provider_b=provider_type_strategy, name_a=resource_name_strategy, name_b=resource_name_strategy, ) @settings(max_examples=100) def test_non_conflicting_names_unchanged(self, provider_a, provider_b, name_a, name_b): """Resources with unique names across providers are not prefixed.""" assume(provider_a != provider_b) assume(name_a != name_b) resource_a = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_a][0], unique_id="id-a", name=name_a, provider=provider_a, platform_category=PROVIDER_PLATFORM_MAP[provider_a], architecture=CpuArchitecture.AMD64, endpoint="http://host-a:8080", attributes={}, ) resource_b = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_b][0], unique_id="id-b", name=name_b, provider=provider_b, platform_category=PROVIDER_PLATFORM_MAP[provider_b], architecture=CpuArchitecture.AMD64, endpoint="http://host-b:8080", attributes={}, ) scan_result_a = ScanResult( resources=[resource_a], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) scan_result_b = ScanResult( resources=[resource_b], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) merger = ResourceMerger() merged = merger.merge([scan_result_a, scan_result_b]) # No resources lost assert len(merged) == 2 # Names should remain unchanged (no prefix) merged_names = {r.name for r in merged} assert name_a in merged_names, ( f"Expected original name '{name_a}' preserved, got: {merged_names}" ) assert name_b in merged_names, ( f"Expected original name '{name_b}' preserved, got: {merged_names}" ) @given( provider_a=provider_type_strategy, provider_b=provider_type_strategy, provider_c=provider_type_strategy, shared_name=resource_name_strategy, ) @settings(max_examples=100) def test_three_provider_conflict_all_prefixed( self, provider_a, provider_b, provider_c, shared_name ): """When 3 providers share a name, all get prefixed.""" assume(len({provider_a, provider_b, provider_c}) == 3) resources_and_results = [] for provider in [provider_a, provider_b, provider_c]: resource = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider][0], unique_id=f"id-{provider.value}", name=shared_name, provider=provider, platform_category=PROVIDER_PLATFORM_MAP[provider], architecture=CpuArchitecture.AMD64, endpoint=f"http://{provider.value}:8080", attributes={}, ) result = ScanResult( resources=[resource], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) resources_and_results.append(result) merger = ResourceMerger() merged = merger.merge(resources_and_results) # All 3 resources preserved assert len(merged) == 3 # All must be prefixed for provider in [provider_a, provider_b, provider_c]: expected = f"{provider.value}_{shared_name}" assert any(r.name == expected for r in merged), ( f"Expected prefixed name '{expected}' in merged results" ) @given( provider_a=provider_type_strategy, provider_b=provider_type_strategy, shared_name=resource_name_strategy, ) @settings(max_examples=100) def test_merge_preserves_all_resources_no_loss( self, provider_a, provider_b, shared_name ): """Merging never loses resources regardless of naming conflicts.""" assume(provider_a != provider_b) resource_a = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_a][0], unique_id="id-a", name=shared_name, provider=provider_a, platform_category=PROVIDER_PLATFORM_MAP[provider_a], architecture=CpuArchitecture.AMD64, endpoint="http://host-a:8080", attributes={"source": "a"}, ) resource_b = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_b][0], unique_id="id-b", name=shared_name, provider=provider_b, platform_category=PROVIDER_PLATFORM_MAP[provider_b], architecture=CpuArchitecture.AMD64, endpoint="http://host-b:8080", attributes={"source": "b"}, ) # Also add a non-conflicting resource resource_c = DiscoveredResource( resource_type=PROVIDER_SUPPORTED_RESOURCE_TYPES[provider_a][0], unique_id="id-c", name="unique_resource_name", provider=provider_a, platform_category=PROVIDER_PLATFORM_MAP[provider_a], architecture=CpuArchitecture.AMD64, endpoint="http://host-a:8080", attributes={"source": "c"}, ) scan_result_a = ScanResult( resources=[resource_a, resource_c], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) scan_result_b = ScanResult( resources=[resource_b], warnings=[], errors=[], scan_timestamp="", profile_hash="", ) merger = ResourceMerger() merged = merger.merge([scan_result_a, scan_result_b]) # Total resources = 3 (no loss) assert len(merged) == 3 # Provider-specific attributes preserved unique_ids = {r.unique_id for r in merged} assert "id-a" in unique_ids assert "id-b" in unique_ids assert "id-c" in unique_ids # --------------------------------------------------------------------------- # Property 19: Provider block generation # --------------------------------------------------------------------------- class TestProviderBlockGeneration: """Property 19: Provider block generation. For any set of providers used, a provider block is generated for each. **Validates: Requirements 5.4** """ @given( providers=st.lists( provider_type_strategy, min_size=1, max_size=6, ).map(lambda ps: list(set(ps))), # Deduplicate ) @settings(max_examples=100) def test_one_provider_block_per_distinct_provider(self, providers): """Generated output contains exactly one provider block per distinct provider.""" assume(len(providers) >= 1) # Create profiles for each provider profiles = [ ScanProfile( provider=p, credentials={"token": "test-token"}, ) for p in providers ] provider_types = set(providers) generator = ProviderBlockGenerator() result = generator.generate(profiles=profiles, provider_types=provider_types) # Result should be a GeneratedFile assert isinstance(result, GeneratedFile) assert result.filename == "providers.tf" content = result.content # Count provider blocks: each provider type should have exactly one # provider "name" { block for provider_type in provider_types: # Get the terraform provider name for this type from iac_reverse.generator.provider_block import _PROVIDER_METADATA tf_name = _PROVIDER_METADATA[provider_type][0] provider_block_marker = f'provider "{tf_name}"' count = content.count(provider_block_marker) assert count == 1, ( f"Expected exactly 1 provider block for '{tf_name}', " f"found {count} in:\n{content}" ) @given( providers=st.lists( provider_type_strategy, min_size=2, max_size=6, ).map(lambda ps: list(set(ps))), ) @settings(max_examples=100) def test_required_providers_block_lists_all(self, providers): """The terraform required_providers block lists all providers used.""" assume(len(providers) >= 2) profiles = [ ScanProfile( provider=p, credentials={"token": "test-token"}, ) for p in providers ] provider_types = set(providers) generator = ProviderBlockGenerator() result = generator.generate(profiles=profiles, provider_types=provider_types) content = result.content # The required_providers block must exist assert "required_providers" in content # Each provider must appear in the required_providers block from iac_reverse.generator.provider_block import _PROVIDER_METADATA for provider_type in provider_types: tf_name, source, _ = _PROVIDER_METADATA[provider_type] assert tf_name in content, ( f"Expected provider name '{tf_name}' in required_providers block" ) assert source in content, ( f"Expected source '{source}' in required_providers block" ) @given(provider=provider_type_strategy) @settings(max_examples=100) def test_single_provider_generates_one_block(self, provider): """A single provider generates exactly one provider block.""" profiles = [ ScanProfile( provider=provider, credentials={"token": "test-token"}, ) ] generator = ProviderBlockGenerator() result = generator.generate( profiles=profiles, provider_types={provider}, ) content = result.content from iac_reverse.generator.provider_block import _PROVIDER_METADATA tf_name = _PROVIDER_METADATA[provider][0] # Exactly one provider block provider_block_marker = f'provider "{tf_name}"' assert content.count(provider_block_marker) == 1 # terraform block with required_providers assert "terraform {" in content assert "required_providers" in content # --------------------------------------------------------------------------- # Property 20: Scan profile validation completeness (multi-provider scenarios) # --------------------------------------------------------------------------- class TestScanProfileValidationMultiProvider: """Property 20: Scan profile validation completeness (multi-provider scenarios). Additional multi-provider scenarios beyond what's in test_scan_profile_validation_prop.py. **Validates: Requirements 6.1, 6.6, 6.7** """ @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_valid_multi_provider_profiles_all_pass_validation( self, provider, credentials ): """Each valid profile in a multi-provider set passes validation independently.""" profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=None, ) errors = profile.validate() assert errors == [], f"Expected no errors for valid profile, got: {errors}" @given( provider_a=provider_type_strategy, provider_b=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_mixed_valid_invalid_profiles_detected_independently( self, provider_a, provider_b, credentials ): """Invalid profiles are detected independently in a multi-provider set.""" # Valid profile valid_profile = ScanProfile( provider=provider_a, credentials=credentials, resource_type_filters=None, ) # Invalid profile (empty credentials) invalid_profile = ScanProfile( provider=provider_b, credentials={}, resource_type_filters=None, ) valid_errors = valid_profile.validate() invalid_errors = invalid_profile.validate() assert valid_errors == [] assert len(invalid_errors) >= 1 assert any("credentials" in e for e in invalid_errors) @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_cross_provider_resource_types_detected_as_unsupported( self, provider, credentials ): """Resource types from a different provider are flagged as unsupported.""" # Pick a resource type from a different provider other_providers = [p for p in ProviderType if p != provider] assume(len(other_providers) > 0) other_provider = other_providers[0] other_types = PROVIDER_SUPPORTED_RESOURCE_TYPES[other_provider] profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=other_types[:2], ) errors = profile.validate() # Should detect unsupported types (unless they happen to overlap) supported = set(PROVIDER_SUPPORTED_RESOURCE_TYPES[provider]) unsupported = [t for t in other_types[:2] if t not in supported] if unsupported: assert any("unsupported" in e.lower() for e in errors), ( f"Expected unsupported error for cross-provider types, got: {errors}" ) # --------------------------------------------------------------------------- # Property 21: Filtering correctness # --------------------------------------------------------------------------- class TestFilteringCorrectness: """Property 21: Filtering correctness. When resource type filters are specified, only those types appear in the scan result. **Validates: Requirements 6.2, 6.4** """ @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_filtered_scan_returns_only_filtered_types(self, provider, credentials): """When filters are specified, only filtered resource types appear in results.""" supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] assume(len(supported) >= 2) # Pick a subset of supported types as filter filter_types = supported[:2] plugin = FilteringPlugin(provider=provider) profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=filter_types, ) scanner = Scanner(profile=profile, plugin=plugin) result = scanner.scan() # All discovered resources must have types in the filter list for resource in result.resources: assert resource.resource_type in filter_types, ( f"Resource type '{resource.resource_type}' not in filter list " f"{filter_types}" ) @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_no_filter_returns_all_supported_types(self, provider, credentials): """When no filters are specified, all supported types are discovered.""" supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] plugin = FilteringPlugin(provider=provider) profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=None, ) scanner = Scanner(profile=profile, plugin=plugin) result = scanner.scan() # All supported types should be discovered discovered_types = {r.resource_type for r in result.resources} for rt in supported: assert rt in discovered_types, ( f"Expected type '{rt}' to be discovered when no filter, " f"got: {discovered_types}" ) @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_single_type_filter_returns_only_that_type(self, provider, credentials): """A single-type filter returns only resources of that type.""" supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] assume(len(supported) >= 1) single_filter = [supported[0]] plugin = FilteringPlugin(provider=provider) profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=single_filter, ) scanner = Scanner(profile=profile, plugin=plugin) result = scanner.scan() # Only the single filtered type should appear for resource in result.resources: assert resource.resource_type == single_filter[0], ( f"Expected only '{single_filter[0]}', got '{resource.resource_type}'" ) @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_empty_filter_returns_no_resources(self, provider, credentials): """An empty filter list results in no resources discovered.""" plugin = FilteringPlugin(provider=provider) profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=[], ) scanner = Scanner(profile=profile, plugin=plugin) result = scanner.scan() # Empty filter means nothing to discover assert len(result.resources) == 0, ( f"Expected 0 resources with empty filter, got {len(result.resources)}" ) @given( provider=provider_type_strategy, credentials=non_empty_credentials_strategy, ) @settings(max_examples=100) def test_filter_subset_excludes_non_filtered_types(self, provider, credentials): """Types not in the filter list do not appear in results.""" supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider] assume(len(supported) >= 3) # Filter to first 2 types only filter_types = supported[:2] excluded_types = supported[2:] plugin = FilteringPlugin(provider=provider) profile = ScanProfile( provider=provider, credentials=credentials, resource_type_filters=filter_types, ) scanner = Scanner(profile=profile, plugin=plugin) result = scanner.scan() # None of the excluded types should appear discovered_types = {r.resource_type for r in result.resources} for excluded in excluded_types: assert excluded not in discovered_types, ( f"Excluded type '{excluded}' should not appear in filtered results" )