Files
SnarfCode/tests/unit/test_code_generator.py
2026-05-22 00:19:30 -04:00

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