640 lines
22 KiB
Python
640 lines
22 KiB
Python
"""Unit tests for the CodeGenerator."""
|
|
|
|
import pytest
|
|
|
|
from iac_reverse.models import (
|
|
CodeGenerationResult,
|
|
CpuArchitecture,
|
|
DependencyGraph,
|
|
DiscoveredResource,
|
|
GeneratedFile,
|
|
PlatformCategory,
|
|
ProviderType,
|
|
ResourceRelationship,
|
|
ScanProfile,
|
|
)
|
|
from iac_reverse.generator import CodeGenerator
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_resource(
|
|
resource_type: str = "kubernetes_deployment",
|
|
unique_id: str = "default/deployments/nginx",
|
|
name: str = "nginx",
|
|
provider: ProviderType = ProviderType.KUBERNETES,
|
|
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
|
architecture: CpuArchitecture = CpuArchitecture.AARCH64,
|
|
attributes: dict | None = None,
|
|
raw_references: list[str] | None = None,
|
|
) -> DiscoveredResource:
|
|
"""Create a sample DiscoveredResource for testing."""
|
|
return DiscoveredResource(
|
|
resource_type=resource_type,
|
|
unique_id=unique_id,
|
|
name=name,
|
|
provider=provider,
|
|
platform_category=platform_category,
|
|
architecture=architecture,
|
|
endpoint="https://k8s-api.local:6443",
|
|
attributes=attributes or {},
|
|
raw_references=raw_references or [],
|
|
)
|
|
|
|
|
|
def make_graph(
|
|
resources: list[DiscoveredResource],
|
|
relationships: list[ResourceRelationship] | None = None,
|
|
) -> DependencyGraph:
|
|
"""Create a DependencyGraph from resources and optional relationships."""
|
|
return DependencyGraph(
|
|
resources=resources,
|
|
relationships=relationships or [],
|
|
topological_order=[r.unique_id for r in resources],
|
|
cycles=[],
|
|
unresolved_references=[],
|
|
)
|
|
|
|
|
|
def make_profiles() -> list[ScanProfile]:
|
|
"""Create a default list of scan profiles for testing."""
|
|
return [
|
|
ScanProfile(
|
|
provider=ProviderType.KUBERNETES,
|
|
credentials={"kubeconfig_path": "/home/user/.kube/config"},
|
|
)
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Single resource generates valid HCL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSingleResourceGeneration:
|
|
"""Tests for generating HCL from a single resource."""
|
|
|
|
def test_single_resource_produces_one_file(self):
|
|
"""A single resource produces exactly one resource file."""
|
|
resource = make_resource(
|
|
attributes={"replicas": 3, "image": "nginx:1.25"},
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert len(result.resource_files) == 1
|
|
|
|
def test_single_resource_file_has_correct_filename(self):
|
|
"""The generated file is named after the resource type."""
|
|
resource = make_resource(resource_type="kubernetes_deployment")
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.resource_files[0].filename == "kubernetes_deployment.tf"
|
|
|
|
def test_single_resource_file_contains_resource_block(self):
|
|
"""The generated file contains a resource block with the correct type."""
|
|
resource = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
name="nginx",
|
|
attributes={"replicas": 3},
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert 'resource "kubernetes_deployment" "nginx"' in content
|
|
|
|
def test_single_resource_includes_attributes(self):
|
|
"""The generated resource block includes all attributes."""
|
|
resource = make_resource(
|
|
attributes={"replicas": 3, "image": "nginx:1.25"},
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert "replicas = 3" in content
|
|
assert 'image = "nginx:1.25"' in content
|
|
|
|
def test_single_resource_resource_count_is_one(self):
|
|
"""The resource_count for a single resource file is 1."""
|
|
resource = make_resource()
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.resource_files[0].resource_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Multiple resources of same type go in one file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSameTypeGrouping:
|
|
"""Tests for grouping multiple resources of the same type into one file."""
|
|
|
|
def test_two_resources_same_type_produce_one_file(self):
|
|
"""Two resources of the same type produce exactly one file."""
|
|
resource_a = make_resource(
|
|
unique_id="default/deployments/app-a",
|
|
name="app-a",
|
|
attributes={"replicas": 2},
|
|
)
|
|
resource_b = make_resource(
|
|
unique_id="default/deployments/app-b",
|
|
name="app-b",
|
|
attributes={"replicas": 1},
|
|
)
|
|
graph = make_graph([resource_a, resource_b])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert len(result.resource_files) == 1
|
|
|
|
def test_two_resources_same_type_both_in_file(self):
|
|
"""Both resources appear in the same file."""
|
|
resource_a = make_resource(
|
|
unique_id="default/deployments/app-a",
|
|
name="app-a",
|
|
attributes={"replicas": 2},
|
|
)
|
|
resource_b = make_resource(
|
|
unique_id="default/deployments/app-b",
|
|
name="app-b",
|
|
attributes={"replicas": 1},
|
|
)
|
|
graph = make_graph([resource_a, resource_b])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert 'resource "kubernetes_deployment" "app_a"' in content
|
|
assert 'resource "kubernetes_deployment" "app_b"' in content
|
|
|
|
def test_resource_count_matches_number_of_resources(self):
|
|
"""The resource_count reflects the number of resources in the file."""
|
|
resources = [
|
|
make_resource(
|
|
unique_id=f"default/deployments/app-{i}",
|
|
name=f"app-{i}",
|
|
)
|
|
for i in range(3)
|
|
]
|
|
graph = make_graph(resources)
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.resource_files[0].resource_count == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Different resource types go in separate files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDifferentTypesSeparateFiles:
|
|
"""Tests for separating different resource types into different files."""
|
|
|
|
def test_two_different_types_produce_two_files(self):
|
|
"""Two resources of different types produce two files."""
|
|
deployment = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="default/deployments/nginx",
|
|
name="nginx",
|
|
)
|
|
service = make_resource(
|
|
resource_type="kubernetes_service",
|
|
unique_id="default/services/nginx-svc",
|
|
name="nginx-svc",
|
|
)
|
|
graph = make_graph([deployment, service])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert len(result.resource_files) == 2
|
|
|
|
def test_different_types_have_correct_filenames(self):
|
|
"""Each file is named after its resource type."""
|
|
deployment = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="default/deployments/nginx",
|
|
name="nginx",
|
|
)
|
|
service = make_resource(
|
|
resource_type="kubernetes_service",
|
|
unique_id="default/services/nginx-svc",
|
|
name="nginx-svc",
|
|
)
|
|
graph = make_graph([deployment, service])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
filenames = {f.filename for f in result.resource_files}
|
|
|
|
assert "kubernetes_deployment.tf" in filenames
|
|
assert "kubernetes_service.tf" in filenames
|
|
|
|
def test_each_file_contains_only_its_type(self):
|
|
"""Each file contains only resource blocks of its designated type."""
|
|
deployment = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="default/deployments/nginx",
|
|
name="nginx",
|
|
)
|
|
service = make_resource(
|
|
resource_type="kubernetes_service",
|
|
unique_id="default/services/nginx-svc",
|
|
name="nginx-svc",
|
|
)
|
|
graph = make_graph([deployment, service])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
for f in result.resource_files:
|
|
if f.filename == "kubernetes_deployment.tf":
|
|
assert "kubernetes_deployment" in f.content
|
|
assert 'resource "kubernetes_service"' not in f.content
|
|
elif f.filename == "kubernetes_service.tf":
|
|
assert "kubernetes_service" in f.content
|
|
assert 'resource "kubernetes_deployment"' not in f.content
|
|
|
|
def test_three_types_produce_three_files(self):
|
|
"""Three distinct resource types produce three files."""
|
|
resources = [
|
|
make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="default/deployments/app",
|
|
name="app",
|
|
),
|
|
make_resource(
|
|
resource_type="kubernetes_service",
|
|
unique_id="default/services/app-svc",
|
|
name="app-svc",
|
|
),
|
|
make_resource(
|
|
resource_type="windows_service",
|
|
unique_id="win/services/iis",
|
|
name="iis",
|
|
provider=ProviderType.WINDOWS,
|
|
platform_category=PlatformCategory.WINDOWS,
|
|
architecture=CpuArchitecture.AMD64,
|
|
),
|
|
]
|
|
graph = make_graph(resources)
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert len(result.resource_files) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Traceability comments are present
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTraceabilityComments:
|
|
"""Tests for traceability comments in generated HCL."""
|
|
|
|
def test_resource_block_has_source_comment(self):
|
|
"""Each resource block is preceded by a comment with the unique_id."""
|
|
resource = make_resource(
|
|
unique_id="apps/v1/deployments/default/nginx",
|
|
name="nginx",
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert "# Source: apps/v1/deployments/default/nginx" in content
|
|
|
|
def test_multiple_resources_each_have_source_comment(self):
|
|
"""Each resource in a multi-resource file has its own source comment."""
|
|
resource_a = make_resource(
|
|
unique_id="default/deployments/app-a",
|
|
name="app-a",
|
|
)
|
|
resource_b = make_resource(
|
|
unique_id="default/deployments/app-b",
|
|
name="app-b",
|
|
)
|
|
graph = make_graph([resource_a, resource_b])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert "# Source: default/deployments/app-a" in content
|
|
assert "# Source: default/deployments/app-b" in content
|
|
|
|
def test_windows_resource_has_source_comment(self):
|
|
"""Windows resources also have traceability comments."""
|
|
resource = make_resource(
|
|
resource_type="windows_service",
|
|
unique_id="win-server-01/services/W3SVC",
|
|
name="W3SVC",
|
|
provider=ProviderType.WINDOWS,
|
|
platform_category=PlatformCategory.WINDOWS,
|
|
architecture=CpuArchitecture.AMD64,
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert "# Source: win-server-01/services/W3SVC" in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Architecture tags are included
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestArchitectureTags:
|
|
"""Tests for architecture-specific tags/labels on resources."""
|
|
|
|
def test_aarch64_resource_has_arch_tag(self):
|
|
"""An AArch64 resource includes arch = aarch64 in tags."""
|
|
resource = make_resource(
|
|
architecture=CpuArchitecture.AARCH64,
|
|
attributes={"replicas": 1},
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert '"arch" = "aarch64"' in content
|
|
|
|
def test_amd64_resource_has_arch_tag(self):
|
|
"""An AMD64 resource includes arch = amd64 in tags."""
|
|
resource = make_resource(
|
|
resource_type="windows_service",
|
|
unique_id="win/services/svc",
|
|
name="svc",
|
|
architecture=CpuArchitecture.AMD64,
|
|
provider=ProviderType.WINDOWS,
|
|
platform_category=PlatformCategory.WINDOWS,
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert '"arch" = "amd64"' in content
|
|
|
|
def test_arm_resource_has_arch_tag(self):
|
|
"""An ARM resource includes arch = arm in tags."""
|
|
resource = make_resource(
|
|
architecture=CpuArchitecture.ARM,
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert '"arch" = "arm"' in content
|
|
|
|
def test_managed_by_tag_is_present(self):
|
|
"""All resources include a managed_by = iac-reverse tag."""
|
|
resource = make_resource()
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert '"managed_by" = "iac-reverse"' in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Dependencies use Terraform references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTerraformReferences:
|
|
"""Tests for Terraform resource references in generated HCL."""
|
|
|
|
def test_dependency_uses_terraform_reference(self):
|
|
"""A resource referencing another uses a Terraform reference expression."""
|
|
namespace = make_resource(
|
|
resource_type="kubernetes_namespace",
|
|
unique_id="ns/default",
|
|
name="default",
|
|
)
|
|
deployment = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="default/deployments/nginx",
|
|
name="nginx",
|
|
attributes={"namespace": "default"},
|
|
raw_references=["ns/default"],
|
|
)
|
|
relationships = [
|
|
ResourceRelationship(
|
|
source_id="default/deployments/nginx",
|
|
target_id="ns/default",
|
|
relationship_type="parent-child",
|
|
source_attribute="namespace",
|
|
)
|
|
]
|
|
graph = make_graph([namespace, deployment], relationships)
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
# Find the deployment file
|
|
deploy_file = next(
|
|
f for f in result.resource_files
|
|
if f.filename == "kubernetes_deployment.tf"
|
|
)
|
|
content = deploy_file.content
|
|
|
|
# Should use Terraform reference, not hardcoded "default"
|
|
assert "kubernetes_namespace.default.id" in content
|
|
|
|
def test_reference_by_unique_id_in_attribute(self):
|
|
"""An attribute containing a target's unique_id is replaced with a reference."""
|
|
app_pool = make_resource(
|
|
resource_type="windows_iis_app_pool",
|
|
unique_id="win/iis/app_pools/DefaultAppPool",
|
|
name="DefaultAppPool",
|
|
provider=ProviderType.WINDOWS,
|
|
platform_category=PlatformCategory.WINDOWS,
|
|
architecture=CpuArchitecture.AMD64,
|
|
)
|
|
site = make_resource(
|
|
resource_type="windows_iis_site",
|
|
unique_id="win/iis/sites/MySite",
|
|
name="MySite",
|
|
attributes={"app_pool": "win/iis/app_pools/DefaultAppPool"},
|
|
provider=ProviderType.WINDOWS,
|
|
platform_category=PlatformCategory.WINDOWS,
|
|
architecture=CpuArchitecture.AMD64,
|
|
)
|
|
relationships = [
|
|
ResourceRelationship(
|
|
source_id="win/iis/sites/MySite",
|
|
target_id="win/iis/app_pools/DefaultAppPool",
|
|
relationship_type="dependency",
|
|
source_attribute="app_pool",
|
|
)
|
|
]
|
|
graph = make_graph([app_pool, site], relationships)
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
site_file = next(
|
|
f for f in result.resource_files
|
|
if f.filename == "windows_iis_site.tf"
|
|
)
|
|
content = site_file.content
|
|
|
|
assert "windows_iis_app_pool.DefaultAppPool.id" in content
|
|
|
|
def test_non_reference_attributes_remain_literal(self):
|
|
"""Attributes that don't reference other resources remain as literals."""
|
|
resource = make_resource(
|
|
attributes={"replicas": 3, "image": "nginx:1.25"},
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
assert "replicas = 3" in content
|
|
assert 'image = "nginx:1.25"' in content
|
|
|
|
def test_multiple_dependencies_all_use_references(self):
|
|
"""A resource with multiple dependencies uses references for all."""
|
|
namespace = make_resource(
|
|
resource_type="kubernetes_namespace",
|
|
unique_id="ns/prod",
|
|
name="prod",
|
|
)
|
|
config_map = make_resource(
|
|
resource_type="kubernetes_config_map",
|
|
unique_id="prod/configmaps/app-config",
|
|
name="app-config",
|
|
)
|
|
deployment = make_resource(
|
|
resource_type="kubernetes_deployment",
|
|
unique_id="prod/deployments/app",
|
|
name="app",
|
|
attributes={
|
|
"namespace": "prod",
|
|
"config_map": "app-config",
|
|
"replicas": 2,
|
|
},
|
|
raw_references=["ns/prod", "prod/configmaps/app-config"],
|
|
)
|
|
relationships = [
|
|
ResourceRelationship(
|
|
source_id="prod/deployments/app",
|
|
target_id="ns/prod",
|
|
relationship_type="parent-child",
|
|
source_attribute="namespace",
|
|
),
|
|
ResourceRelationship(
|
|
source_id="prod/deployments/app",
|
|
target_id="prod/configmaps/app-config",
|
|
relationship_type="dependency",
|
|
source_attribute="config_map",
|
|
),
|
|
]
|
|
graph = make_graph([namespace, config_map, deployment], relationships)
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
deploy_file = next(
|
|
f for f in result.resource_files
|
|
if f.filename == "kubernetes_deployment.tf"
|
|
)
|
|
content = deploy_file.content
|
|
|
|
assert "kubernetes_namespace.prod.id" in content
|
|
assert "kubernetes_config_map.app_config.id" in content
|
|
assert "replicas = 2" in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Result structure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResultStructure:
|
|
"""Tests for the CodeGenerationResult structure."""
|
|
|
|
def test_result_has_variables_file(self):
|
|
"""The result includes a variables_file (empty placeholder for now)."""
|
|
resource = make_resource()
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.variables_file is not None
|
|
assert result.variables_file.filename == "variables.tf"
|
|
|
|
def test_result_has_provider_file(self):
|
|
"""The result includes a provider_file (empty placeholder for now)."""
|
|
resource = make_resource()
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.provider_file is not None
|
|
assert result.provider_file.filename == "providers.tf"
|
|
|
|
def test_empty_graph_produces_no_resource_files(self):
|
|
"""An empty graph produces no resource files."""
|
|
graph = make_graph([])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
|
|
assert result.resource_files == []
|
|
|
|
def test_name_sanitization_replaces_special_chars(self):
|
|
"""Resource names with special characters are sanitized for Terraform."""
|
|
resource = make_resource(
|
|
name="my-app.v2",
|
|
unique_id="default/deployments/my-app.v2",
|
|
)
|
|
graph = make_graph([resource])
|
|
generator = CodeGenerator()
|
|
|
|
result = generator.generate(graph, make_profiles())
|
|
content = result.resource_files[0].content
|
|
|
|
# Should use sanitized name (hyphens and dots replaced with underscores)
|
|
assert 'resource "kubernetes_deployment" "my_app_v2"' in content
|