Created IAC reverse generator
This commit is contained in:
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for IaC Reverse Engineering Tool."""
|
||||
BIN
tests/unit/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tests/unit/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_models.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/unit/__pycache__/test_models.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/unit/__pycache__/test_scanner.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/unit/__pycache__/test_scanner.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
495
tests/unit/test_authentik.py
Normal file
495
tests/unit/test_authentik.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""Unit tests for Authentik authentication and discovery plugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.auth.authentik_auth import (
|
||||
AuthenticationError,
|
||||
AuthentikAuthProvider,
|
||||
AuthentikConfig,
|
||||
AuthentikSession,
|
||||
)
|
||||
from iac_reverse.auth.authentik_discovery import (
|
||||
AuthentikDiscoveryError,
|
||||
AuthentikDiscoveryPlugin,
|
||||
)
|
||||
from iac_reverse.models import CpuArchitecture, PlatformCategory, ScanProgress
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthentikAuthProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikAuthProvider:
|
||||
"""Tests for AuthentikAuthProvider SSO authentication."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = AuthentikAuthProvider()
|
||||
self.config = AuthentikConfig(
|
||||
base_url="https://auth.internal.lab",
|
||||
client_id="iac-reverse-tool",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.post")
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.get")
|
||||
def test_authenticate_user_success(self, mock_get, mock_post):
|
||||
"""Successful authentication returns a valid session."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"access_token": "access-123",
|
||||
"refresh_token": "refresh-456",
|
||||
},
|
||||
)
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"sub": "user-001",
|
||||
"groups": ["admins", "infra-team"],
|
||||
},
|
||||
)
|
||||
|
||||
session = self.provider.authenticate_user(self.config)
|
||||
|
||||
assert session.access_token == "access-123"
|
||||
assert session.refresh_token == "refresh-456"
|
||||
assert session.user_id == "user-001"
|
||||
assert session.groups == ["admins", "infra-team"]
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.post")
|
||||
def test_authenticate_user_failure_status(self, mock_post):
|
||||
"""Authentication failure raises AuthenticationError."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=401,
|
||||
text="Invalid client credentials",
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
self.provider.authenticate_user(self.config)
|
||||
|
||||
assert "Authentik" in str(exc_info.value)
|
||||
assert "401" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.post")
|
||||
def test_authenticate_user_connection_error(self, mock_post):
|
||||
"""Connection error raises AuthenticationError."""
|
||||
import requests
|
||||
|
||||
mock_post.side_effect = requests.ConnectionError("Connection refused")
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
self.provider.authenticate_user(self.config)
|
||||
|
||||
assert "Authentik" in str(exc_info.value)
|
||||
assert "failed to connect" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.post")
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.get")
|
||||
def test_refresh_session_success(self, mock_get, mock_post):
|
||||
"""Successful token refresh returns updated session."""
|
||||
session = AuthentikSession(
|
||||
access_token="old-access",
|
||||
refresh_token="old-refresh",
|
||||
user_id="user-001",
|
||||
groups=["admins"],
|
||||
)
|
||||
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"access_token": "new-access-789",
|
||||
"refresh_token": "new-refresh-012",
|
||||
},
|
||||
)
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"sub": "user-001",
|
||||
"groups": ["admins", "new-group"],
|
||||
},
|
||||
)
|
||||
|
||||
new_session = self.provider.refresh_session(self.config, session)
|
||||
|
||||
assert new_session.access_token == "new-access-789"
|
||||
assert new_session.refresh_token == "new-refresh-012"
|
||||
assert new_session.user_id == "user-001"
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.post")
|
||||
def test_refresh_session_failure(self, mock_post):
|
||||
"""Failed refresh raises AuthenticationError."""
|
||||
session = AuthentikSession(
|
||||
access_token="old-access",
|
||||
refresh_token="expired-refresh",
|
||||
user_id="user-001",
|
||||
groups=[],
|
||||
)
|
||||
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=400,
|
||||
text="Invalid refresh token",
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
self.provider.refresh_session(self.config, session)
|
||||
|
||||
assert "refresh failed" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.get")
|
||||
def test_validate_token_valid(self, mock_get):
|
||||
"""Valid token returns True."""
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
|
||||
result = self.provider.validate_token(self.config, "valid-token")
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.get")
|
||||
def test_validate_token_invalid(self, mock_get):
|
||||
"""Invalid token returns False."""
|
||||
mock_get.return_value = MagicMock(status_code=401)
|
||||
|
||||
result = self.provider.validate_token(self.config, "invalid-token")
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("iac_reverse.auth.authentik_auth.requests.get")
|
||||
def test_validate_token_connection_error(self, mock_get):
|
||||
"""Connection error during validation returns False."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.ConnectionError("timeout")
|
||||
|
||||
result = self.provider.validate_token(self.config, "some-token")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AuthentikDiscoveryPlugin tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikDiscoveryPlugin:
|
||||
"""Tests for AuthentikDiscoveryPlugin resource discovery."""
|
||||
|
||||
def setup_method(self):
|
||||
self.plugin = AuthentikDiscoveryPlugin()
|
||||
self.credentials = {
|
||||
"base_url": "https://auth.internal.lab",
|
||||
"api_token": "test-api-token",
|
||||
}
|
||||
|
||||
def test_get_platform_category(self):
|
||||
"""Plugin returns CONTAINER_ORCHESTRATION category."""
|
||||
assert self.plugin.get_platform_category() == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def test_list_supported_resource_types(self):
|
||||
"""Plugin lists all expected Authentik resource types."""
|
||||
types = self.plugin.list_supported_resource_types()
|
||||
|
||||
expected = [
|
||||
"authentik_flow",
|
||||
"authentik_stage",
|
||||
"authentik_provider",
|
||||
"authentik_application",
|
||||
"authentik_outpost",
|
||||
"authentik_property_mapping",
|
||||
"authentik_certificate",
|
||||
"authentik_group",
|
||||
"authentik_source",
|
||||
]
|
||||
assert types == expected
|
||||
|
||||
def test_detect_architecture_defaults_to_amd64(self):
|
||||
"""Architecture detection defaults to AMD64."""
|
||||
arch = self.plugin.detect_architecture("https://auth.internal.lab")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
def test_list_endpoints_before_auth(self):
|
||||
"""Endpoints list is empty before authentication."""
|
||||
assert self.plugin.list_endpoints() == []
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_authenticate_success(self, mock_get):
|
||||
"""Successful authentication sets internal state."""
|
||||
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
assert self.plugin.list_endpoints() == ["https://auth.internal.lab"]
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_authenticate_invalid_token(self, mock_get):
|
||||
"""Invalid API token raises AuthentikDiscoveryError."""
|
||||
mock_get.return_value = MagicMock(status_code=401)
|
||||
|
||||
with pytest.raises(AuthentikDiscoveryError) as exc_info:
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
assert "invalid API token" in str(exc_info.value)
|
||||
|
||||
def test_authenticate_missing_base_url(self):
|
||||
"""Missing base_url raises AuthentikDiscoveryError."""
|
||||
with pytest.raises(AuthentikDiscoveryError) as exc_info:
|
||||
self.plugin.authenticate({"api_token": "token"})
|
||||
|
||||
assert "base_url" in str(exc_info.value)
|
||||
|
||||
def test_authenticate_missing_api_token(self):
|
||||
"""Missing api_token raises AuthentikDiscoveryError."""
|
||||
with pytest.raises(AuthentikDiscoveryError) as exc_info:
|
||||
self.plugin.authenticate({"base_url": "https://auth.lab"})
|
||||
|
||||
assert "api_token" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_authenticate_connection_error(self, mock_get):
|
||||
"""Connection error during auth raises AuthentikDiscoveryError."""
|
||||
import requests
|
||||
|
||||
mock_get.side_effect = requests.ConnectionError("refused")
|
||||
|
||||
with pytest.raises(AuthentikDiscoveryError) as exc_info:
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
assert "failed to connect" in str(exc_info.value)
|
||||
|
||||
def test_discover_resources_without_auth(self):
|
||||
"""Discovering without authentication raises error."""
|
||||
with pytest.raises(AuthentikDiscoveryError) as exc_info:
|
||||
self.plugin.discover_resources(
|
||||
["https://auth.lab"],
|
||||
["authentik_flow"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
assert "must authenticate" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_flows(self, mock_get):
|
||||
"""Discovers Authentik flows from the API."""
|
||||
# First call is for authentication check
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
|
||||
# Second call is for flow discovery
|
||||
flow_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [
|
||||
{
|
||||
"pk": "flow-uuid-1",
|
||||
"name": "default-authentication-flow",
|
||||
"slug": "default-authentication-flow",
|
||||
"stages": ["stage-1", "stage-2"],
|
||||
},
|
||||
{
|
||||
"pk": "flow-uuid-2",
|
||||
"name": "default-enrollment-flow",
|
||||
"slug": "default-enrollment-flow",
|
||||
"stages": ["stage-3"],
|
||||
},
|
||||
],
|
||||
"pagination": {"next": 0, "count": 2},
|
||||
},
|
||||
)
|
||||
|
||||
mock_get.side_effect = [auth_response, flow_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
progress_updates: list[ScanProgress] = []
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_flow"],
|
||||
lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].resource_type == "authentik_flow"
|
||||
assert result.resources[0].name == "default-authentication-flow"
|
||||
assert result.resources[0].unique_id == "authentik/authentik_flow/flow-uuid-1"
|
||||
assert "stage-1" in result.resources[0].raw_references
|
||||
assert len(result.warnings) == 0
|
||||
assert len(result.errors) == 0
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_multiple_types(self, mock_get):
|
||||
"""Discovers multiple resource types in one call."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
|
||||
app_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [
|
||||
{
|
||||
"pk": "app-1",
|
||||
"name": "grafana",
|
||||
"slug": "grafana",
|
||||
"provider": "provider-1",
|
||||
}
|
||||
],
|
||||
"pagination": {"next": 0, "count": 1},
|
||||
},
|
||||
)
|
||||
|
||||
group_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [
|
||||
{"pk": "group-1", "name": "admins"},
|
||||
{"pk": "group-2", "name": "users"},
|
||||
],
|
||||
"pagination": {"next": 0, "count": 2},
|
||||
},
|
||||
)
|
||||
|
||||
mock_get.side_effect = [auth_response, app_response, group_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_application", "authentik_group"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 3
|
||||
app_resources = [r for r in result.resources if r.resource_type == "authentik_application"]
|
||||
group_resources = [r for r in result.resources if r.resource_type == "authentik_group"]
|
||||
assert len(app_resources) == 1
|
||||
assert len(group_resources) == 2
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_unsupported_type_warning(self, mock_get):
|
||||
"""Unsupported resource type produces a warning."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
mock_get.side_effect = [auth_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_nonexistent"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.warnings) == 1
|
||||
assert "Unsupported" in result.warnings[0]
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_api_error(self, mock_get):
|
||||
"""API error during discovery is captured in errors list."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
error_response = MagicMock(status_code=500)
|
||||
|
||||
mock_get.side_effect = [auth_response, error_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_flow"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "authentik_flow" in result.errors[0]
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_pagination(self, mock_get):
|
||||
"""Handles paginated API responses correctly."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
|
||||
page1_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [{"pk": "cert-1", "name": "cert-one"}],
|
||||
"pagination": {"next": 2, "count": 2},
|
||||
},
|
||||
)
|
||||
page2_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [{"pk": "cert-2", "name": "cert-two"}],
|
||||
"pagination": {"next": 0, "count": 2},
|
||||
},
|
||||
)
|
||||
|
||||
mock_get.side_effect = [auth_response, page1_response, page2_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_certificate"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].name == "cert-one"
|
||||
assert result.resources[1].name == "cert-two"
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_discover_resources_progress_callback(self, mock_get):
|
||||
"""Progress callback is invoked correctly during discovery."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
empty_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"results": [], "pagination": {"next": 0, "count": 0}},
|
||||
)
|
||||
|
||||
mock_get.side_effect = [auth_response, empty_response, empty_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
progress_updates: list[ScanProgress] = []
|
||||
self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_flow", "authentik_stage"],
|
||||
lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
# Should have progress for each type + final "complete"
|
||||
assert len(progress_updates) == 3
|
||||
assert progress_updates[0].current_resource_type == "authentik_flow"
|
||||
assert progress_updates[1].current_resource_type == "authentik_stage"
|
||||
assert progress_updates[2].current_resource_type == "complete"
|
||||
|
||||
@patch("iac_reverse.auth.authentik_discovery.requests.get")
|
||||
def test_extract_references_from_resource(self, mock_get):
|
||||
"""References are extracted from API response fields."""
|
||||
auth_response = MagicMock(status_code=200, json=lambda: {"results": []})
|
||||
app_response = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"results": [
|
||||
{
|
||||
"pk": "app-1",
|
||||
"name": "my-app",
|
||||
"provider": "provider-uuid-1",
|
||||
"group": "group-uuid-1",
|
||||
}
|
||||
],
|
||||
"pagination": {"next": 0, "count": 1},
|
||||
},
|
||||
)
|
||||
|
||||
mock_get.side_effect = [auth_response, app_response]
|
||||
|
||||
self.plugin.authenticate(self.credentials)
|
||||
|
||||
result = self.plugin.discover_resources(
|
||||
["https://auth.internal.lab"],
|
||||
["authentik_application"],
|
||||
lambda p: None,
|
||||
)
|
||||
|
||||
refs = result.resources[0].raw_references
|
||||
assert "provider-uuid-1" in refs
|
||||
assert "group-uuid-1" in refs
|
||||
664
tests/unit/test_bare_metal_plugin.py
Normal file
664
tests/unit/test_bare_metal_plugin.py
Normal file
@@ -0,0 +1,664 @@
|
||||
"""Unit tests for the BareMetalPlugin provider plugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProgress,
|
||||
)
|
||||
from iac_reverse.scanner import AuthenticationError
|
||||
from iac_reverse.scanner.bare_metal_plugin import BareMetalPlugin
|
||||
|
||||
|
||||
class TestBareMetalPluginInterface:
|
||||
"""Tests for basic plugin interface compliance."""
|
||||
|
||||
def test_implements_provider_plugin(self):
|
||||
"""BareMetalPlugin can be instantiated (implements all abstract methods)."""
|
||||
plugin = BareMetalPlugin()
|
||||
assert plugin is not None
|
||||
|
||||
def test_get_platform_category(self):
|
||||
"""Returns PlatformCategory.BARE_METAL."""
|
||||
plugin = BareMetalPlugin()
|
||||
assert plugin.get_platform_category() == PlatformCategory.BARE_METAL
|
||||
|
||||
def test_list_supported_resource_types(self):
|
||||
"""Returns the expected bare metal resource types."""
|
||||
plugin = BareMetalPlugin()
|
||||
expected = [
|
||||
"bare_metal_hardware",
|
||||
"bare_metal_bmc_config",
|
||||
"bare_metal_network_interface",
|
||||
"bare_metal_raid_config",
|
||||
]
|
||||
assert plugin.list_supported_resource_types() == expected
|
||||
|
||||
def test_list_endpoints_before_auth(self):
|
||||
"""Returns empty list before authentication."""
|
||||
plugin = BareMetalPlugin()
|
||||
assert plugin.list_endpoints() == []
|
||||
|
||||
|
||||
class TestBareMetalAuthentication:
|
||||
"""Tests for BMC authentication via Redfish."""
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_success(self, mock_session_cls):
|
||||
"""Successful authentication stores session and sets base URL."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.headers = {"X-Auth-Token": "test-token-123"}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "192.168.1.100",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
})
|
||||
|
||||
assert plugin._host == "192.168.1.100"
|
||||
assert plugin._base_url == "https://192.168.1.100:443"
|
||||
assert plugin._session is not None
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_custom_port(self, mock_session_cls):
|
||||
"""Authentication uses custom port when specified."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"X-Auth-Token": "token"}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "10.0.0.1",
|
||||
"username": "admin",
|
||||
"password": "pass",
|
||||
"port": "8443",
|
||||
})
|
||||
|
||||
assert plugin._base_url == "https://10.0.0.1:8443"
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_no_ssl(self, mock_session_cls):
|
||||
"""Authentication uses HTTP when use_ssl is false."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"X-Auth-Token": "token"}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "10.0.0.1",
|
||||
"username": "admin",
|
||||
"password": "pass",
|
||||
"use_ssl": "false",
|
||||
})
|
||||
|
||||
assert plugin._base_url == "http://10.0.0.1:443"
|
||||
|
||||
def test_authenticate_missing_host(self):
|
||||
"""Raises AuthenticationError when host is missing."""
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"username": "admin", "password": "pass"})
|
||||
assert "Missing required credentials" in str(exc_info.value)
|
||||
|
||||
def test_authenticate_missing_username(self):
|
||||
"""Raises AuthenticationError when username is missing."""
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError):
|
||||
plugin.authenticate({"host": "10.0.0.1", "password": "pass"})
|
||||
|
||||
def test_authenticate_missing_password(self):
|
||||
"""Raises AuthenticationError when password is missing."""
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError):
|
||||
plugin.authenticate({"host": "10.0.0.1", "username": "admin"})
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_401_unauthorized(self, mock_session_cls):
|
||||
"""Raises AuthenticationError on HTTP 401."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({
|
||||
"host": "10.0.0.1",
|
||||
"username": "admin",
|
||||
"password": "wrong",
|
||||
})
|
||||
assert "Invalid credentials" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_connection_error(self, mock_session_cls):
|
||||
"""Raises AuthenticationError on connection failure."""
|
||||
import requests
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_session.post.side_effect = requests.exceptions.ConnectionError(
|
||||
"Connection refused"
|
||||
)
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({
|
||||
"host": "unreachable.host",
|
||||
"username": "admin",
|
||||
"password": "pass",
|
||||
})
|
||||
assert "Cannot connect" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_authenticate_timeout(self, mock_session_cls):
|
||||
"""Raises AuthenticationError on timeout."""
|
||||
import requests
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_session.post.side_effect = requests.exceptions.Timeout("Timed out")
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({
|
||||
"host": "slow.host",
|
||||
"username": "admin",
|
||||
"password": "pass",
|
||||
})
|
||||
assert "timed out" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_list_endpoints_after_auth(self, mock_session_cls):
|
||||
"""Returns host as endpoint after successful authentication."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.headers = {"X-Auth-Token": "token"}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
plugin = BareMetalPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "192.168.1.50",
|
||||
"username": "admin",
|
||||
"password": "pass",
|
||||
})
|
||||
|
||||
assert plugin.list_endpoints() == ["192.168.1.50"]
|
||||
|
||||
|
||||
class TestBareMetalArchitectureDetection:
|
||||
"""Tests for CPU architecture detection via Redfish."""
|
||||
|
||||
def test_detect_architecture_no_session(self):
|
||||
"""Returns AMD64 default when no session is available."""
|
||||
plugin = BareMetalPlugin()
|
||||
assert plugin.detect_architecture("10.0.0.1") == CpuArchitecture.AMD64
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_detect_architecture_amd64(self, mock_session_cls):
|
||||
"""Detects AMD64 architecture from processor data."""
|
||||
plugin = BareMetalPlugin()
|
||||
mock_session = MagicMock()
|
||||
plugin._session = mock_session
|
||||
plugin._base_url = "https://10.0.0.1:443"
|
||||
|
||||
# Mock processors collection response
|
||||
proc_collection_response = MagicMock()
|
||||
proc_collection_response.status_code = 200
|
||||
proc_collection_response.json.return_value = {
|
||||
"Members": [{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU.1"}]
|
||||
}
|
||||
|
||||
# Mock individual processor response
|
||||
proc_response = MagicMock()
|
||||
proc_response.status_code = 200
|
||||
proc_response.json.return_value = {
|
||||
"InstructionSet": "x86-64",
|
||||
"Model": "Intel Xeon E5-2680 v4",
|
||||
}
|
||||
|
||||
mock_session.get.side_effect = [proc_collection_response, proc_response]
|
||||
|
||||
result = plugin.detect_architecture("10.0.0.1")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_detect_architecture_aarch64(self, mock_session_cls):
|
||||
"""Detects AARCH64 architecture from processor data."""
|
||||
plugin = BareMetalPlugin()
|
||||
mock_session = MagicMock()
|
||||
plugin._session = mock_session
|
||||
plugin._base_url = "https://10.0.0.1:443"
|
||||
|
||||
proc_collection_response = MagicMock()
|
||||
proc_collection_response.status_code = 200
|
||||
proc_collection_response.json.return_value = {
|
||||
"Members": [{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU.1"}]
|
||||
}
|
||||
|
||||
proc_response = MagicMock()
|
||||
proc_response.status_code = 200
|
||||
proc_response.json.return_value = {
|
||||
"InstructionSet": "AArch64",
|
||||
"Model": "Ampere Altra Q80-30",
|
||||
}
|
||||
|
||||
mock_session.get.side_effect = [proc_collection_response, proc_response]
|
||||
|
||||
result = plugin.detect_architecture("10.0.0.1")
|
||||
assert result == CpuArchitecture.AARCH64
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_detect_architecture_arm_from_model(self, mock_session_cls):
|
||||
"""Detects ARM architecture from model string."""
|
||||
plugin = BareMetalPlugin()
|
||||
mock_session = MagicMock()
|
||||
plugin._session = mock_session
|
||||
plugin._base_url = "https://10.0.0.1:443"
|
||||
|
||||
proc_collection_response = MagicMock()
|
||||
proc_collection_response.status_code = 200
|
||||
proc_collection_response.json.return_value = {
|
||||
"Members": [{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU.1"}]
|
||||
}
|
||||
|
||||
proc_response = MagicMock()
|
||||
proc_response.status_code = 200
|
||||
proc_response.json.return_value = {
|
||||
"InstructionSet": "",
|
||||
"Model": "ARM Cortex-A53",
|
||||
}
|
||||
|
||||
mock_session.get.side_effect = [proc_collection_response, proc_response]
|
||||
|
||||
result = plugin.detect_architecture("10.0.0.1")
|
||||
assert result == CpuArchitecture.ARM
|
||||
|
||||
@patch("iac_reverse.scanner.bare_metal_plugin.requests.Session")
|
||||
def test_detect_architecture_fallback_on_error(self, mock_session_cls):
|
||||
"""Falls back to AMD64 on request error."""
|
||||
plugin = BareMetalPlugin()
|
||||
mock_session = MagicMock()
|
||||
plugin._session = mock_session
|
||||
plugin._base_url = "https://10.0.0.1:443"
|
||||
|
||||
mock_session.get.side_effect = Exception("Network error")
|
||||
|
||||
result = plugin.detect_architecture("10.0.0.1")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
class TestBareMetalDiscoverResources:
|
||||
"""Tests for resource discovery via Redfish."""
|
||||
|
||||
def _make_authenticated_plugin(self):
|
||||
"""Create a plugin with a mocked session."""
|
||||
plugin = BareMetalPlugin()
|
||||
plugin._session = MagicMock()
|
||||
plugin._base_url = "https://10.0.0.1:443"
|
||||
plugin._host = "10.0.0.1"
|
||||
return plugin
|
||||
|
||||
def test_discover_hardware(self):
|
||||
"""Discovers hardware inventory from /redfish/v1/Systems/1."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection (for architecture)
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {
|
||||
"Members": [{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU.1"}]
|
||||
}
|
||||
proc_detail = MagicMock()
|
||||
proc_detail.status_code = 200
|
||||
proc_detail.json.return_value = {
|
||||
"InstructionSet": "x86-64",
|
||||
"Model": "Intel Xeon",
|
||||
}
|
||||
|
||||
# Mock system response
|
||||
system_response = MagicMock()
|
||||
system_response.status_code = 200
|
||||
system_response.json.return_value = {
|
||||
"Id": "System.Embedded.1",
|
||||
"Name": "Dell PowerEdge R740",
|
||||
"Manufacturer": "Dell Inc.",
|
||||
"Model": "PowerEdge R740",
|
||||
"SerialNumber": "ABC123",
|
||||
"SKU": "R740",
|
||||
"BiosVersion": "2.12.2",
|
||||
"MemorySummary": {"TotalSystemMemoryGiB": 256},
|
||||
"ProcessorSummary": {"Count": 2, "Model": "Intel Xeon Gold 6248"},
|
||||
"PowerState": "On",
|
||||
"Status": {"State": "Enabled", "Health": "OK"},
|
||||
}
|
||||
|
||||
plugin._session.get.side_effect = [
|
||||
proc_collection, proc_detail, system_response
|
||||
]
|
||||
|
||||
progress_calls = []
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_hardware"],
|
||||
progress_callback=lambda p: progress_calls.append(p),
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "bare_metal_hardware"
|
||||
assert resource.unique_id == "10.0.0.1:System.Embedded.1"
|
||||
assert resource.name == "Dell PowerEdge R740"
|
||||
assert resource.provider == ProviderType.BARE_METAL
|
||||
assert resource.platform_category == PlatformCategory.BARE_METAL
|
||||
assert resource.architecture == CpuArchitecture.AMD64
|
||||
assert resource.attributes["manufacturer"] == "Dell Inc."
|
||||
assert resource.attributes["total_memory_gib"] == 256
|
||||
assert resource.attributes["processor_count"] == 2
|
||||
assert len(progress_calls) == 1
|
||||
|
||||
def test_discover_bmc_config(self):
|
||||
"""Discovers BMC configuration from /redfish/v1/Managers/1."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {"Members": []}
|
||||
|
||||
# Mock manager response
|
||||
manager_response = MagicMock()
|
||||
manager_response.status_code = 200
|
||||
manager_response.json.return_value = {
|
||||
"Id": "iDRAC.Embedded.1",
|
||||
"Name": "iDRAC Manager",
|
||||
"ManagerType": "BMC",
|
||||
"FirmwareVersion": "5.10.50.00",
|
||||
"Model": "iDRAC9",
|
||||
"Status": {"State": "Enabled", "Health": "OK"},
|
||||
"UUID": "abc-def-123",
|
||||
}
|
||||
|
||||
plugin._session.get.side_effect = [proc_collection, manager_response]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_bmc_config"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "bare_metal_bmc_config"
|
||||
assert resource.unique_id == "10.0.0.1:iDRAC.Embedded.1"
|
||||
assert resource.attributes["firmware_version"] == "5.10.50.00"
|
||||
assert resource.attributes["manager_type"] == "BMC"
|
||||
|
||||
def test_discover_network_interfaces(self):
|
||||
"""Discovers network interfaces from Redfish EthernetInterfaces."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {"Members": []}
|
||||
|
||||
# Mock NIC collection
|
||||
nic_collection = MagicMock()
|
||||
nic_collection.status_code = 200
|
||||
nic_collection.json.return_value = {
|
||||
"Members": [
|
||||
{"@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces/NIC.1"},
|
||||
{"@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces/NIC.2"},
|
||||
]
|
||||
}
|
||||
|
||||
# Mock individual NIC responses
|
||||
nic1_response = MagicMock()
|
||||
nic1_response.status_code = 200
|
||||
nic1_response.json.return_value = {
|
||||
"Id": "NIC.Integrated.1-1",
|
||||
"Name": "Ethernet Interface 1",
|
||||
"MACAddress": "AA:BB:CC:DD:EE:01",
|
||||
"SpeedMbps": 10000,
|
||||
"Status": {"State": "Enabled", "Health": "OK"},
|
||||
"IPv4Addresses": [{"Address": "192.168.1.10"}],
|
||||
"IPv6Addresses": [],
|
||||
"LinkStatus": "LinkUp",
|
||||
"AutoNeg": True,
|
||||
}
|
||||
|
||||
nic2_response = MagicMock()
|
||||
nic2_response.status_code = 200
|
||||
nic2_response.json.return_value = {
|
||||
"Id": "NIC.Integrated.1-2",
|
||||
"Name": "Ethernet Interface 2",
|
||||
"MACAddress": "AA:BB:CC:DD:EE:02",
|
||||
"SpeedMbps": 1000,
|
||||
"Status": {"State": "Enabled", "Health": "OK"},
|
||||
"IPv4Addresses": [],
|
||||
"IPv6Addresses": [],
|
||||
"LinkStatus": "LinkDown",
|
||||
"AutoNeg": True,
|
||||
}
|
||||
|
||||
plugin._session.get.side_effect = [
|
||||
proc_collection, nic_collection, nic1_response, nic2_response
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_network_interface"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].attributes["mac_address"] == "AA:BB:CC:DD:EE:01"
|
||||
assert result.resources[0].attributes["speed_mbps"] == 10000
|
||||
assert result.resources[1].attributes["mac_address"] == "AA:BB:CC:DD:EE:02"
|
||||
|
||||
def test_discover_raid_config(self):
|
||||
"""Discovers RAID configuration from Redfish Storage."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {"Members": []}
|
||||
|
||||
# Mock storage collection
|
||||
storage_collection = MagicMock()
|
||||
storage_collection.status_code = 200
|
||||
storage_collection.json.return_value = {
|
||||
"Members": [
|
||||
{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID.Integrated.1-1"}
|
||||
]
|
||||
}
|
||||
|
||||
# Mock storage controller detail
|
||||
storage_detail = MagicMock()
|
||||
storage_detail.status_code = 200
|
||||
storage_detail.json.return_value = {
|
||||
"Id": "RAID.Integrated.1-1",
|
||||
"Name": "PERC H740P Mini",
|
||||
"StorageControllers": [{"Name": "PERC H740P Mini"}],
|
||||
"Drives": [
|
||||
{"@odata.id": "/redfish/v1/Systems/1/Storage/Drives/Disk.0"},
|
||||
{"@odata.id": "/redfish/v1/Systems/1/Storage/Drives/Disk.1"},
|
||||
],
|
||||
"Volumes": {
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID.Integrated.1-1/Volumes"
|
||||
},
|
||||
"Status": {"State": "Enabled", "Health": "OK"},
|
||||
}
|
||||
|
||||
# Mock volumes collection
|
||||
volumes_response = MagicMock()
|
||||
volumes_response.status_code = 200
|
||||
volumes_response.json.return_value = {
|
||||
"Members": [
|
||||
{"@odata.id": "/redfish/v1/Systems/1/Storage/Volumes/Disk.Virtual.0"}
|
||||
]
|
||||
}
|
||||
|
||||
plugin._session.get.side_effect = [
|
||||
proc_collection, storage_collection, storage_detail, volumes_response
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_raid_config"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "bare_metal_raid_config"
|
||||
assert resource.attributes["drive_count"] == 2
|
||||
assert len(resource.attributes["volumes"]) == 1
|
||||
assert resource.attributes["storage_controllers"] == ["PERC H740P Mini"]
|
||||
|
||||
def test_discover_multiple_resource_types(self):
|
||||
"""Discovers multiple resource types in a single call."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {"Members": []}
|
||||
|
||||
# Mock system response
|
||||
system_response = MagicMock()
|
||||
system_response.status_code = 200
|
||||
system_response.json.return_value = {
|
||||
"Id": "System.1",
|
||||
"Name": "Server",
|
||||
"Manufacturer": "Dell",
|
||||
"Model": "R740",
|
||||
}
|
||||
|
||||
# Mock manager response
|
||||
manager_response = MagicMock()
|
||||
manager_response.status_code = 200
|
||||
manager_response.json.return_value = {
|
||||
"Id": "BMC.1",
|
||||
"Name": "BMC",
|
||||
"ManagerType": "BMC",
|
||||
"FirmwareVersion": "1.0",
|
||||
}
|
||||
|
||||
plugin._session.get.side_effect = [
|
||||
proc_collection, system_response, manager_response
|
||||
]
|
||||
|
||||
progress_calls = []
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_hardware", "bare_metal_bmc_config"],
|
||||
progress_callback=lambda p: progress_calls.append(p),
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert len(progress_calls) == 2
|
||||
assert progress_calls[0].resource_types_completed == 1
|
||||
assert progress_calls[1].resource_types_completed == 2
|
||||
|
||||
def test_discover_handles_errors_gracefully(self):
|
||||
"""Errors during discovery are captured, not raised."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection that raises - this causes detect_architecture
|
||||
# to fail, which is caught internally. Then the resource handler also
|
||||
# needs to raise to trigger the error capture in discover_resources.
|
||||
plugin._session.get.side_effect = Exception("Server unreachable")
|
||||
|
||||
# Patch _discover_resource_type to raise so the outer handler catches it
|
||||
with patch.object(
|
||||
plugin, "_discover_resource_type", side_effect=Exception("Server unreachable")
|
||||
):
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["bare_metal_hardware"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "Server unreachable" in result.errors[0]
|
||||
|
||||
def test_discover_unsupported_resource_type(self):
|
||||
"""Unsupported resource types return empty results without error."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Mock processor detection
|
||||
proc_collection = MagicMock()
|
||||
proc_collection.status_code = 200
|
||||
proc_collection.json.return_value = {"Members": []}
|
||||
|
||||
plugin._session.get.side_effect = [proc_collection]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["unknown_type"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
class TestParseArchitecture:
|
||||
"""Tests for the _parse_architecture static method."""
|
||||
|
||||
def test_x86_64_instruction_set(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "x86-64", "Model": "Intel Xeon"}
|
||||
) == CpuArchitecture.AMD64
|
||||
|
||||
def test_aarch64_instruction_set(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "AArch64", "Model": "Ampere"}
|
||||
) == CpuArchitecture.AARCH64
|
||||
|
||||
def test_arm_instruction_set(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "ARM", "Model": "Cortex"}
|
||||
) == CpuArchitecture.AARCH64
|
||||
|
||||
def test_arm_model_32bit(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "", "Model": "ARM Cortex-A7"}
|
||||
) == CpuArchitecture.ARM
|
||||
|
||||
def test_arm_model_64bit(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "", "Model": "ARM v8 Processor"}
|
||||
) == CpuArchitecture.AARCH64
|
||||
|
||||
def test_empty_data_defaults_amd64(self):
|
||||
assert BareMetalPlugin._parse_architecture(
|
||||
{"InstructionSet": "", "Model": ""}
|
||||
) == CpuArchitecture.AMD64
|
||||
335
tests/unit/test_change_detector.py
Normal file
335
tests/unit/test_change_detector.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Unit tests for the ChangeDetector class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.incremental.change_detector import ChangeDetector
|
||||
from iac_reverse.models import (
|
||||
ChangeSummary,
|
||||
ChangeType,
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
|
||||
|
||||
def _make_resource(
|
||||
unique_id: str = "res-1",
|
||||
name: str = "test-resource",
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
attributes: dict | None = None,
|
||||
) -> DiscoveredResource:
|
||||
"""Create a sample DiscoveredResource for testing."""
|
||||
return DiscoveredResource(
|
||||
resource_type=resource_type,
|
||||
unique_id=unique_id,
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AMD64,
|
||||
endpoint="https://k8s-api.internal.lab:6443",
|
||||
attributes=attributes if attributes is not None else {"replicas": 3},
|
||||
)
|
||||
|
||||
|
||||
def _make_scan_result(
|
||||
resources: list[DiscoveredResource] | None = None,
|
||||
) -> ScanResult:
|
||||
"""Create a sample ScanResult for testing."""
|
||||
return ScanResult(
|
||||
resources=resources if resources is not None else [],
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="abc123",
|
||||
)
|
||||
|
||||
|
||||
class TestNoChanges:
|
||||
"""Tests for identical scans producing no changes."""
|
||||
|
||||
def test_identical_scans_produce_no_changes(self) -> None:
|
||||
"""Comparing identical scans returns empty change summary."""
|
||||
resource = _make_resource(unique_id="res-1", attributes={"replicas": 3})
|
||||
current = _make_scan_result(resources=[resource])
|
||||
previous = _make_scan_result(resources=[resource])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 0
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 0
|
||||
assert summary.changes == []
|
||||
|
||||
def test_empty_scans_produce_no_changes(self) -> None:
|
||||
"""Comparing two empty scans returns empty change summary."""
|
||||
current = _make_scan_result(resources=[])
|
||||
previous = _make_scan_result(resources=[])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 0
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 0
|
||||
assert summary.changes == []
|
||||
|
||||
|
||||
class TestAddedResources:
|
||||
"""Tests for detecting added resources."""
|
||||
|
||||
def test_new_resource_detected_as_added(self) -> None:
|
||||
"""A resource in current but not in previous is classified as ADDED."""
|
||||
resource = _make_resource(unique_id="new-res", name="new-service")
|
||||
current = _make_scan_result(resources=[resource])
|
||||
previous = _make_scan_result(resources=[])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 1
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 0
|
||||
assert len(summary.changes) == 1
|
||||
|
||||
change = summary.changes[0]
|
||||
assert change.resource_id == "new-res"
|
||||
assert change.resource_name == "new-service"
|
||||
assert change.change_type == ChangeType.ADDED
|
||||
assert change.changed_attributes is None
|
||||
|
||||
def test_multiple_added_resources(self) -> None:
|
||||
"""Multiple new resources are all classified as ADDED."""
|
||||
res1 = _make_resource(unique_id="res-1", name="service-1")
|
||||
res2 = _make_resource(unique_id="res-2", name="service-2")
|
||||
current = _make_scan_result(resources=[res1, res2])
|
||||
previous = _make_scan_result(resources=[])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 2
|
||||
added_ids = {c.resource_id for c in summary.changes}
|
||||
assert added_ids == {"res-1", "res-2"}
|
||||
|
||||
|
||||
class TestRemovedResources:
|
||||
"""Tests for detecting removed resources."""
|
||||
|
||||
def test_missing_resource_detected_as_removed(self) -> None:
|
||||
"""A resource in previous but not in current is classified as REMOVED."""
|
||||
resource = _make_resource(unique_id="old-res", name="old-service")
|
||||
current = _make_scan_result(resources=[])
|
||||
previous = _make_scan_result(resources=[resource])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 0
|
||||
assert summary.removed_count == 1
|
||||
assert summary.modified_count == 0
|
||||
assert len(summary.changes) == 1
|
||||
|
||||
change = summary.changes[0]
|
||||
assert change.resource_id == "old-res"
|
||||
assert change.resource_name == "old-service"
|
||||
assert change.change_type == ChangeType.REMOVED
|
||||
assert change.changed_attributes is None
|
||||
|
||||
def test_multiple_removed_resources(self) -> None:
|
||||
"""Multiple missing resources are all classified as REMOVED."""
|
||||
res1 = _make_resource(unique_id="res-1", name="service-1")
|
||||
res2 = _make_resource(unique_id="res-2", name="service-2")
|
||||
current = _make_scan_result(resources=[])
|
||||
previous = _make_scan_result(resources=[res1, res2])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.removed_count == 2
|
||||
removed_ids = {c.resource_id for c in summary.changes}
|
||||
assert removed_ids == {"res-1", "res-2"}
|
||||
|
||||
|
||||
class TestModifiedResources:
|
||||
"""Tests for detecting modified resources."""
|
||||
|
||||
def test_changed_attributes_detected_as_modified(self) -> None:
|
||||
"""A resource with changed attributes is classified as MODIFIED."""
|
||||
prev_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.24"}
|
||||
)
|
||||
curr_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 5, "image": "nginx:1.24"}
|
||||
)
|
||||
current = _make_scan_result(resources=[curr_resource])
|
||||
previous = _make_scan_result(resources=[prev_resource])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 0
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 1
|
||||
assert len(summary.changes) == 1
|
||||
|
||||
change = summary.changes[0]
|
||||
assert change.resource_id == "res-1"
|
||||
assert change.change_type == ChangeType.MODIFIED
|
||||
assert change.changed_attributes == {"replicas": {"old": 3, "new": 5}}
|
||||
|
||||
def test_added_attribute_detected_as_modified(self) -> None:
|
||||
"""A resource with a new attribute key is classified as MODIFIED."""
|
||||
prev_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 3}
|
||||
)
|
||||
curr_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.25"}
|
||||
)
|
||||
current = _make_scan_result(resources=[curr_resource])
|
||||
previous = _make_scan_result(resources=[prev_resource])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.modified_count == 1
|
||||
change = summary.changes[0]
|
||||
assert change.changed_attributes == {
|
||||
"image": {"old": None, "new": "nginx:1.25"}
|
||||
}
|
||||
|
||||
def test_removed_attribute_detected_as_modified(self) -> None:
|
||||
"""A resource with a removed attribute key is classified as MODIFIED."""
|
||||
prev_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.25"}
|
||||
)
|
||||
curr_resource = _make_resource(
|
||||
unique_id="res-1", attributes={"replicas": 3}
|
||||
)
|
||||
current = _make_scan_result(resources=[curr_resource])
|
||||
previous = _make_scan_result(resources=[prev_resource])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.modified_count == 1
|
||||
change = summary.changes[0]
|
||||
assert change.changed_attributes == {
|
||||
"image": {"old": "nginx:1.25", "new": None}
|
||||
}
|
||||
|
||||
|
||||
class TestMixedChanges:
|
||||
"""Tests for scans with a mix of added, removed, and modified resources."""
|
||||
|
||||
def test_mixed_changes_detected_correctly(self) -> None:
|
||||
"""A scan with added, removed, and modified resources is classified correctly."""
|
||||
# Shared resource (modified)
|
||||
prev_shared = _make_resource(
|
||||
unique_id="shared", name="shared-svc", attributes={"replicas": 2}
|
||||
)
|
||||
curr_shared = _make_resource(
|
||||
unique_id="shared", name="shared-svc", attributes={"replicas": 4}
|
||||
)
|
||||
|
||||
# Removed resource
|
||||
removed = _make_resource(unique_id="old-res", name="old-svc")
|
||||
|
||||
# Added resource
|
||||
added = _make_resource(unique_id="new-res", name="new-svc")
|
||||
|
||||
previous = _make_scan_result(resources=[prev_shared, removed])
|
||||
current = _make_scan_result(resources=[curr_shared, added])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.added_count == 1
|
||||
assert summary.removed_count == 1
|
||||
assert summary.modified_count == 1
|
||||
assert len(summary.changes) == 3
|
||||
|
||||
change_map = {c.resource_id: c for c in summary.changes}
|
||||
assert change_map["new-res"].change_type == ChangeType.ADDED
|
||||
assert change_map["old-res"].change_type == ChangeType.REMOVED
|
||||
assert change_map["shared"].change_type == ChangeType.MODIFIED
|
||||
|
||||
|
||||
class TestFirstScan:
|
||||
"""Tests for first scan (no previous snapshot)."""
|
||||
|
||||
def test_first_scan_treats_all_as_added(self) -> None:
|
||||
"""When previous is None, all current resources are classified as ADDED."""
|
||||
res1 = _make_resource(unique_id="res-1", name="service-1")
|
||||
res2 = _make_resource(unique_id="res-2", name="service-2")
|
||||
current = _make_scan_result(resources=[res1, res2])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous=None)
|
||||
|
||||
assert summary.added_count == 2
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 0
|
||||
assert len(summary.changes) == 2
|
||||
assert all(c.change_type == ChangeType.ADDED for c in summary.changes)
|
||||
|
||||
def test_first_scan_empty_produces_empty_summary(self) -> None:
|
||||
"""First scan with no resources produces empty change summary."""
|
||||
current = _make_scan_result(resources=[])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous=None)
|
||||
|
||||
assert summary.added_count == 0
|
||||
assert summary.removed_count == 0
|
||||
assert summary.modified_count == 0
|
||||
assert summary.changes == []
|
||||
|
||||
|
||||
class TestChangeSummaryCounts:
|
||||
"""Tests that ChangeSummary counts are always correct."""
|
||||
|
||||
def test_counts_match_change_list(self) -> None:
|
||||
"""The counts in ChangeSummary always match the actual changes list."""
|
||||
# Set up a scenario with 2 added, 1 removed, 1 modified
|
||||
prev_mod = _make_resource(
|
||||
unique_id="mod-1", name="mod-svc", attributes={"port": 80}
|
||||
)
|
||||
curr_mod = _make_resource(
|
||||
unique_id="mod-1", name="mod-svc", attributes={"port": 8080}
|
||||
)
|
||||
removed = _make_resource(unique_id="rem-1", name="rem-svc")
|
||||
added1 = _make_resource(unique_id="add-1", name="add-svc-1")
|
||||
added2 = _make_resource(unique_id="add-2", name="add-svc-2")
|
||||
|
||||
previous = _make_scan_result(resources=[prev_mod, removed])
|
||||
current = _make_scan_result(resources=[curr_mod, added1, added2])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
# Verify counts match actual changes
|
||||
actual_added = [c for c in summary.changes if c.change_type == ChangeType.ADDED]
|
||||
actual_removed = [c for c in summary.changes if c.change_type == ChangeType.REMOVED]
|
||||
actual_modified = [c for c in summary.changes if c.change_type == ChangeType.MODIFIED]
|
||||
|
||||
assert summary.added_count == len(actual_added) == 2
|
||||
assert summary.removed_count == len(actual_removed) == 1
|
||||
assert summary.modified_count == len(actual_modified) == 1
|
||||
|
||||
def test_resource_type_preserved_in_changes(self) -> None:
|
||||
"""ResourceChange objects preserve the resource_type from the resource."""
|
||||
resource = _make_resource(
|
||||
unique_id="res-1",
|
||||
resource_type="docker_service",
|
||||
name="my-service",
|
||||
)
|
||||
current = _make_scan_result(resources=[resource])
|
||||
previous = _make_scan_result(resources=[])
|
||||
|
||||
detector = ChangeDetector()
|
||||
summary = detector.compare(current, previous)
|
||||
|
||||
assert summary.changes[0].resource_type == "docker_service"
|
||||
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
|
||||
639
tests/unit/test_code_generator.py
Normal file
639
tests/unit/test_code_generator.py
Normal file
@@ -0,0 +1,639 @@
|
||||
"""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
|
||||
444
tests/unit/test_docker_swarm_plugin.py
Normal file
444
tests/unit/test_docker_swarm_plugin.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""Unit tests for the DockerSwarmPlugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProgress,
|
||||
)
|
||||
from iac_reverse.scanner import AuthenticationError
|
||||
from iac_reverse.scanner.docker_swarm_plugin import DockerSwarmPlugin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin():
|
||||
"""Create a fresh DockerSwarmPlugin instance."""
|
||||
return DockerSwarmPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client():
|
||||
"""Create a mock Docker client with common attributes."""
|
||||
client = MagicMock()
|
||||
client.ping.return_value = True
|
||||
client.info.return_value = {"Architecture": "x86_64"}
|
||||
client.services.list.return_value = []
|
||||
client.networks.list.return_value = []
|
||||
client.volumes.list.return_value = []
|
||||
client.configs.list.return_value = []
|
||||
client.secrets.list.return_value = []
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_plugin(plugin, mock_docker_client):
|
||||
"""Return a plugin that has been authenticated with a mock client."""
|
||||
with patch("iac_reverse.scanner.docker_swarm_plugin.docker.DockerClient") as mock_cls:
|
||||
mock_cls.return_value = mock_docker_client
|
||||
plugin.authenticate({"host": "tcp://localhost:2376"})
|
||||
return plugin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
def test_authenticate_success(self, plugin):
|
||||
"""Successful authentication connects to Docker daemon."""
|
||||
with patch("iac_reverse.scanner.docker_swarm_plugin.docker.DockerClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.return_value = True
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
plugin.authenticate({"host": "tcp://192.168.1.10:2376"})
|
||||
|
||||
mock_cls.assert_called_once_with(
|
||||
base_url="tcp://192.168.1.10:2376",
|
||||
tls=False,
|
||||
)
|
||||
mock_client.ping.assert_called_once()
|
||||
|
||||
def test_authenticate_missing_host(self, plugin):
|
||||
"""Authentication fails when host is not provided."""
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({})
|
||||
|
||||
assert "host" in str(exc_info.value).lower()
|
||||
|
||||
def test_authenticate_empty_host(self, plugin):
|
||||
"""Authentication fails when host is empty string."""
|
||||
with pytest.raises(AuthenticationError):
|
||||
plugin.authenticate({"host": ""})
|
||||
|
||||
def test_authenticate_connection_failure(self, plugin):
|
||||
"""Authentication fails when Docker daemon is unreachable."""
|
||||
with patch("iac_reverse.scanner.docker_swarm_plugin.docker.DockerClient") as mock_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.side_effect = ConnectionError("Connection refused")
|
||||
mock_cls.return_value = mock_client
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"host": "tcp://unreachable:2376"})
|
||||
|
||||
assert "Connection refused" in str(exc_info.value)
|
||||
|
||||
def test_authenticate_with_tls(self, plugin):
|
||||
"""Authentication configures TLS when tls_verify and cert_path are set."""
|
||||
with patch("iac_reverse.scanner.docker_swarm_plugin.docker.DockerClient") as mock_cls:
|
||||
with patch("iac_reverse.scanner.docker_swarm_plugin.TLSConfig") as mock_tls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.return_value = True
|
||||
mock_cls.return_value = mock_client
|
||||
mock_tls_instance = MagicMock()
|
||||
mock_tls.return_value = mock_tls_instance
|
||||
|
||||
plugin.authenticate({
|
||||
"host": "tcp://secure-host:2376",
|
||||
"tls_verify": "true",
|
||||
"cert_path": "/certs",
|
||||
})
|
||||
|
||||
mock_tls.assert_called_once_with(
|
||||
verify=True,
|
||||
client_cert=("/certs/cert.pem", "/certs/key.pem"),
|
||||
ca_cert="/certs/ca.pem",
|
||||
)
|
||||
mock_cls.assert_called_once_with(
|
||||
base_url="tcp://secure-host:2376",
|
||||
tls=mock_tls_instance,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform category and resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformInfo:
|
||||
def test_get_platform_category(self, plugin):
|
||||
"""Returns CONTAINER_ORCHESTRATION category."""
|
||||
assert plugin.get_platform_category() == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def test_list_supported_resource_types(self, plugin):
|
||||
"""Returns all five Docker Swarm resource types."""
|
||||
types = plugin.list_supported_resource_types()
|
||||
assert types == [
|
||||
"docker_service",
|
||||
"docker_network",
|
||||
"docker_volume",
|
||||
"docker_config",
|
||||
"docker_secret",
|
||||
]
|
||||
|
||||
def test_list_endpoints_before_auth(self, plugin):
|
||||
"""Returns empty list before authentication."""
|
||||
assert plugin.list_endpoints() == []
|
||||
|
||||
def test_list_endpoints_after_auth(self, authenticated_plugin):
|
||||
"""Returns the host URL after authentication."""
|
||||
assert authenticated_plugin.list_endpoints() == ["tcp://localhost:2376"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Architecture detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectArchitecture:
|
||||
def test_detect_amd64(self, authenticated_plugin, mock_docker_client):
|
||||
"""Detects AMD64 architecture from x86_64 info."""
|
||||
mock_docker_client.info.return_value = {"Architecture": "x86_64"}
|
||||
arch = authenticated_plugin.detect_architecture("tcp://localhost:2376")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
def test_detect_aarch64(self, authenticated_plugin, mock_docker_client):
|
||||
"""Detects AARCH64 architecture from aarch64 info."""
|
||||
mock_docker_client.info.return_value = {"Architecture": "aarch64"}
|
||||
arch = authenticated_plugin.detect_architecture("tcp://localhost:2376")
|
||||
assert arch == CpuArchitecture.AARCH64
|
||||
|
||||
def test_detect_arm(self, authenticated_plugin, mock_docker_client):
|
||||
"""Detects ARM architecture from armv7l info."""
|
||||
mock_docker_client.info.return_value = {"Architecture": "armv7l"}
|
||||
arch = authenticated_plugin.detect_architecture("tcp://localhost:2376")
|
||||
assert arch == CpuArchitecture.ARM
|
||||
|
||||
def test_detect_unknown_defaults_to_amd64(self, authenticated_plugin, mock_docker_client):
|
||||
"""Unknown architecture string defaults to AMD64."""
|
||||
mock_docker_client.info.return_value = {"Architecture": "sparc"}
|
||||
arch = authenticated_plugin.detect_architecture("tcp://localhost:2376")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
def test_detect_without_client(self, plugin):
|
||||
"""Returns AMD64 when no client is connected."""
|
||||
arch = plugin.detect_architecture("tcp://localhost:2376")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoverResources:
|
||||
def _noop_callback(self, progress: ScanProgress) -> None:
|
||||
pass
|
||||
|
||||
def test_discover_services(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers Docker services with correct attributes."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.attrs = {
|
||||
"ID": "svc123",
|
||||
"Spec": {
|
||||
"Name": "web-app",
|
||||
"Mode": {"Replicated": {"Replicas": 3}},
|
||||
"Labels": {"env": "prod"},
|
||||
"TaskTemplate": {
|
||||
"ContainerSpec": {
|
||||
"Image": "nginx:latest",
|
||||
},
|
||||
"Networks": [{"Target": "net456"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_docker_client.services.list.return_value = [mock_service]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_service"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
svc = result.resources[0]
|
||||
assert svc.resource_type == "docker_service"
|
||||
assert svc.unique_id == "svc123"
|
||||
assert svc.name == "web-app"
|
||||
assert svc.provider == ProviderType.DOCKER_SWARM
|
||||
assert svc.platform_category == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert svc.attributes["image"] == "nginx:latest"
|
||||
assert svc.attributes["replicas"] == 3
|
||||
assert "network:net456" in svc.raw_references
|
||||
|
||||
def test_discover_networks(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers Docker networks with correct attributes."""
|
||||
mock_network = MagicMock()
|
||||
mock_network.attrs = {
|
||||
"Id": "net789",
|
||||
"Name": "overlay-net",
|
||||
"Driver": "overlay",
|
||||
"Scope": "swarm",
|
||||
"Attachable": True,
|
||||
"Ingress": False,
|
||||
"Labels": {},
|
||||
}
|
||||
mock_docker_client.networks.list.return_value = [mock_network]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_network"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
net = result.resources[0]
|
||||
assert net.resource_type == "docker_network"
|
||||
assert net.unique_id == "net789"
|
||||
assert net.name == "overlay-net"
|
||||
assert net.attributes["driver"] == "overlay"
|
||||
assert net.attributes["scope"] == "swarm"
|
||||
assert net.attributes["attachable"] is True
|
||||
|
||||
def test_discover_volumes(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers Docker volumes with correct attributes."""
|
||||
mock_volume = MagicMock()
|
||||
mock_volume.attrs = {
|
||||
"Name": "data-vol",
|
||||
"Driver": "local",
|
||||
"Mountpoint": "/var/lib/docker/volumes/data-vol/_data",
|
||||
"Labels": {"backup": "daily"},
|
||||
}
|
||||
mock_docker_client.volumes.list.return_value = [mock_volume]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_volume"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
vol = result.resources[0]
|
||||
assert vol.resource_type == "docker_volume"
|
||||
assert vol.unique_id == "data-vol"
|
||||
assert vol.name == "data-vol"
|
||||
assert vol.attributes["driver"] == "local"
|
||||
|
||||
def test_discover_configs(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers Docker configs (metadata only)."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.attrs = {
|
||||
"ID": "cfg001",
|
||||
"Spec": {
|
||||
"Name": "app-config",
|
||||
"Labels": {"version": "2"},
|
||||
},
|
||||
"CreatedAt": "2024-01-01T00:00:00Z",
|
||||
"UpdatedAt": "2024-01-02T00:00:00Z",
|
||||
}
|
||||
mock_docker_client.configs.list.return_value = [mock_config]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_config"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
cfg = result.resources[0]
|
||||
assert cfg.resource_type == "docker_config"
|
||||
assert cfg.unique_id == "cfg001"
|
||||
assert cfg.name == "app-config"
|
||||
assert cfg.attributes["created_at"] == "2024-01-01T00:00:00Z"
|
||||
|
||||
def test_discover_secrets(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers Docker secrets (metadata only, no secret data)."""
|
||||
mock_secret = MagicMock()
|
||||
mock_secret.attrs = {
|
||||
"ID": "sec001",
|
||||
"Spec": {
|
||||
"Name": "db-password",
|
||||
"Labels": {},
|
||||
},
|
||||
"CreatedAt": "2024-01-01T00:00:00Z",
|
||||
"UpdatedAt": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
mock_docker_client.secrets.list.return_value = [mock_secret]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_secret"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
sec = result.resources[0]
|
||||
assert sec.resource_type == "docker_secret"
|
||||
assert sec.unique_id == "sec001"
|
||||
assert sec.name == "db-password"
|
||||
|
||||
def test_discover_all_types(self, authenticated_plugin, mock_docker_client):
|
||||
"""Discovers all resource types in a single call."""
|
||||
mock_docker_client.services.list.return_value = []
|
||||
mock_docker_client.networks.list.return_value = []
|
||||
mock_docker_client.volumes.list.return_value = []
|
||||
mock_docker_client.configs.list.return_value = []
|
||||
mock_docker_client.secrets.list.return_value = []
|
||||
|
||||
all_types = [
|
||||
"docker_service",
|
||||
"docker_network",
|
||||
"docker_volume",
|
||||
"docker_config",
|
||||
"docker_secret",
|
||||
]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=all_types,
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
|
||||
def test_discover_reports_progress(self, authenticated_plugin, mock_docker_client):
|
||||
"""Progress callback is invoked for each resource type."""
|
||||
progress_updates: list[ScanProgress] = []
|
||||
|
||||
def track_progress(p: ScanProgress):
|
||||
progress_updates.append(p)
|
||||
|
||||
authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_service", "docker_network"],
|
||||
progress_callback=track_progress,
|
||||
)
|
||||
|
||||
assert len(progress_updates) == 2
|
||||
assert progress_updates[0].current_resource_type == "docker_service"
|
||||
assert progress_updates[0].resource_types_completed == 1
|
||||
assert progress_updates[0].total_resource_types == 2
|
||||
assert progress_updates[1].current_resource_type == "docker_network"
|
||||
assert progress_updates[1].resource_types_completed == 2
|
||||
|
||||
def test_discover_handles_api_error(self, authenticated_plugin, mock_docker_client):
|
||||
"""API errors are captured in the result errors list."""
|
||||
mock_docker_client.services.list.side_effect = Exception("API timeout")
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_service"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert "API timeout" in result.errors[0]
|
||||
|
||||
def test_discover_without_authentication(self, plugin):
|
||||
"""Returns error when called without authentication."""
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_service"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert "Not authenticated" in result.errors[0]
|
||||
|
||||
def test_service_references_include_volumes_configs_secrets(
|
||||
self, authenticated_plugin, mock_docker_client
|
||||
):
|
||||
"""Service references include network, volume, config, and secret refs."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.attrs = {
|
||||
"ID": "svc-ref",
|
||||
"Spec": {
|
||||
"Name": "full-service",
|
||||
"Mode": {"Replicated": {"Replicas": 1}},
|
||||
"Labels": {},
|
||||
"TaskTemplate": {
|
||||
"ContainerSpec": {
|
||||
"Image": "app:v1",
|
||||
"Mounts": [{"Source": "data-vol"}],
|
||||
"Configs": [{"ConfigID": "cfg-abc"}],
|
||||
"Secrets": [{"SecretID": "sec-xyz"}],
|
||||
},
|
||||
"Networks": [{"Target": "net-123"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_docker_client.services.list.return_value = [mock_service]
|
||||
|
||||
result = authenticated_plugin.discover_resources(
|
||||
endpoints=["tcp://localhost:2376"],
|
||||
resource_types=["docker_service"],
|
||||
progress_callback=self._noop_callback,
|
||||
)
|
||||
|
||||
refs = result.resources[0].raw_references
|
||||
assert "network:net-123" in refs
|
||||
assert "volume:data-vol" in refs
|
||||
assert "config:cfg-abc" in refs
|
||||
assert "secret:sec-xyz" in refs
|
||||
569
tests/unit/test_harvester_plugin.py
Normal file
569
tests/unit/test_harvester_plugin.py
Normal file
@@ -0,0 +1,569 @@
|
||||
"""Unit tests for the HarvesterPlugin provider plugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProgress,
|
||||
)
|
||||
from iac_reverse.scanner.harvester_plugin import HarvesterPlugin
|
||||
|
||||
|
||||
# Patch targets for kubernetes client classes
|
||||
PATCH_NEW_CLIENT = "iac_reverse.scanner.harvester_plugin.config.new_client_from_config"
|
||||
PATCH_CUSTOM_API = "iac_reverse.scanner.harvester_plugin.client.CustomObjectsApi"
|
||||
PATCH_CORE_API = "iac_reverse.scanner.harvester_plugin.client.CoreV1Api"
|
||||
|
||||
|
||||
class TestHarvesterPluginAuthentication:
|
||||
"""Tests for HarvesterPlugin.authenticate()."""
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_authenticate_with_kubeconfig_path(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""Authenticate loads kubeconfig from the provided path."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
mock_new_client.assert_called_once_with(
|
||||
config_file="/path/to/kubeconfig",
|
||||
context=None,
|
||||
)
|
||||
assert plugin._api_client is mock_api_client
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_authenticate_with_context(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""Authenticate uses the optional context parameter."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({
|
||||
"kubeconfig_path": "/path/to/kubeconfig",
|
||||
"context": "harvester-cluster",
|
||||
})
|
||||
|
||||
mock_new_client.assert_called_once_with(
|
||||
config_file="/path/to/kubeconfig",
|
||||
context="harvester-cluster",
|
||||
)
|
||||
|
||||
def test_authenticate_missing_kubeconfig_path(self):
|
||||
"""Authenticate raises AuthenticationError when kubeconfig_path is missing."""
|
||||
from iac_reverse.scanner import AuthenticationError
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({})
|
||||
|
||||
assert "kubeconfig_path" in str(exc_info.value)
|
||||
assert "harvester" in str(exc_info.value)
|
||||
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_authenticate_invalid_kubeconfig(self, mock_new_client):
|
||||
"""Authenticate raises AuthenticationError when kubeconfig is invalid."""
|
||||
from iac_reverse.scanner import AuthenticationError
|
||||
|
||||
mock_new_client.side_effect = Exception("Invalid kubeconfig format")
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"kubeconfig_path": "/bad/path"})
|
||||
|
||||
assert "Failed to load kubeconfig" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestHarvesterPluginMetadata:
|
||||
"""Tests for HarvesterPlugin metadata methods."""
|
||||
|
||||
def test_get_platform_category(self):
|
||||
"""get_platform_category returns HCI."""
|
||||
plugin = HarvesterPlugin()
|
||||
assert plugin.get_platform_category() == PlatformCategory.HCI
|
||||
|
||||
def test_list_supported_resource_types(self):
|
||||
"""list_supported_resource_types returns all Harvester resource types."""
|
||||
plugin = HarvesterPlugin()
|
||||
types = plugin.list_supported_resource_types()
|
||||
|
||||
assert types == [
|
||||
"harvester_virtualmachine",
|
||||
"harvester_volume",
|
||||
"harvester_image",
|
||||
"harvester_network",
|
||||
]
|
||||
|
||||
def test_list_endpoints_unauthenticated(self):
|
||||
"""list_endpoints returns empty list when not authenticated."""
|
||||
plugin = HarvesterPlugin()
|
||||
assert plugin.list_endpoints() == []
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_list_endpoints_authenticated(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""list_endpoints returns the cluster API server URL."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_api_client.configuration.host = "https://harvester.local:6443"
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
endpoints = plugin.list_endpoints()
|
||||
assert endpoints == ["https://harvester.local:6443"]
|
||||
|
||||
|
||||
class TestHarvesterPluginDetectArchitecture:
|
||||
"""Tests for HarvesterPlugin.detect_architecture()."""
|
||||
|
||||
def test_detect_architecture_unauthenticated(self):
|
||||
"""detect_architecture returns AMD64 when not authenticated."""
|
||||
plugin = HarvesterPlugin()
|
||||
arch = plugin.detect_architecture("https://harvester.local:6443")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_detect_architecture_amd64(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""detect_architecture returns AMD64 for amd64 nodes."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
mock_core_instance = MagicMock()
|
||||
mock_core_cls.return_value = mock_core_instance
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
# Mock node list response
|
||||
mock_node = MagicMock()
|
||||
mock_node.status.node_info.architecture = "amd64"
|
||||
mock_core_instance.list_node.return_value = MagicMock(items=[mock_node])
|
||||
|
||||
arch = plugin.detect_architecture("https://harvester.local:6443")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_detect_architecture_arm64(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""detect_architecture returns AARCH64 for arm64 nodes."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
mock_core_instance = MagicMock()
|
||||
mock_core_cls.return_value = mock_core_instance
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
mock_node = MagicMock()
|
||||
mock_node.status.node_info.architecture = "arm64"
|
||||
mock_core_instance.list_node.return_value = MagicMock(items=[mock_node])
|
||||
|
||||
arch = plugin.detect_architecture("https://harvester.local:6443")
|
||||
assert arch == CpuArchitecture.AARCH64
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_detect_architecture_arm(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""detect_architecture returns ARM for arm nodes."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
mock_core_instance = MagicMock()
|
||||
mock_core_cls.return_value = mock_core_instance
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
mock_node = MagicMock()
|
||||
mock_node.status.node_info.architecture = "arm"
|
||||
mock_core_instance.list_node.return_value = MagicMock(items=[mock_node])
|
||||
|
||||
arch = plugin.detect_architecture("https://harvester.local:6443")
|
||||
assert arch == CpuArchitecture.ARM
|
||||
|
||||
@patch(PATCH_CORE_API)
|
||||
@patch(PATCH_CUSTOM_API)
|
||||
@patch(PATCH_NEW_CLIENT)
|
||||
def test_detect_architecture_api_error_defaults_amd64(
|
||||
self, mock_new_client, mock_custom_cls, mock_core_cls
|
||||
):
|
||||
"""detect_architecture defaults to AMD64 on API errors."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
mock_api_client = MagicMock()
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
mock_core_instance = MagicMock()
|
||||
mock_core_cls.return_value = mock_core_instance
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
mock_core_instance.list_node.side_effect = ApiException(status=403)
|
||||
|
||||
arch = plugin.detect_architecture("https://harvester.local:6443")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
def _make_authenticated_plugin():
|
||||
"""Create an authenticated plugin with mocked APIs.
|
||||
|
||||
Returns (plugin, mock_custom_api, mock_core_api) tuple.
|
||||
"""
|
||||
with patch(PATCH_NEW_CLIENT) as mock_new_client, \
|
||||
patch(PATCH_CUSTOM_API) as mock_custom_cls, \
|
||||
patch(PATCH_CORE_API) as mock_core_cls:
|
||||
|
||||
mock_api_client = MagicMock()
|
||||
mock_api_client.configuration.host = "https://harvester.local:6443"
|
||||
mock_new_client.return_value = mock_api_client
|
||||
|
||||
mock_custom_instance = MagicMock()
|
||||
mock_custom_cls.return_value = mock_custom_instance
|
||||
|
||||
mock_core_instance = MagicMock()
|
||||
mock_core_cls.return_value = mock_core_instance
|
||||
|
||||
plugin = HarvesterPlugin()
|
||||
plugin.authenticate({"kubeconfig_path": "/path/to/kubeconfig"})
|
||||
|
||||
# Mock node for architecture detection
|
||||
mock_node = MagicMock()
|
||||
mock_node.status.node_info.architecture = "amd64"
|
||||
mock_core_instance.list_node.return_value = MagicMock(items=[mock_node])
|
||||
|
||||
return plugin, mock_custom_instance, mock_core_instance
|
||||
|
||||
|
||||
class TestHarvesterPluginDiscoverResources:
|
||||
"""Tests for HarvesterPlugin.discover_resources()."""
|
||||
|
||||
def test_discover_vms(self):
|
||||
"""discover_resources discovers virtual machines."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
mock_custom_api.list_cluster_custom_object.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "test-vm",
|
||||
"namespace": "default",
|
||||
"uid": "vm-uid-123",
|
||||
"labels": {"app": "web"},
|
||||
"annotations": {},
|
||||
},
|
||||
"spec": {
|
||||
"running": True,
|
||||
"template": {
|
||||
"spec": {
|
||||
"volumes": [
|
||||
{"dataVolume": {"name": "test-disk"}},
|
||||
],
|
||||
"networks": [
|
||||
{"multus": {"networkName": "vlan100"}},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
progress_updates = []
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_virtualmachine"],
|
||||
progress_callback=lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
vm = result.resources[0]
|
||||
assert vm.resource_type == "harvester_virtualmachine"
|
||||
assert vm.name == "test-vm"
|
||||
assert vm.unique_id == "vm-uid-123"
|
||||
assert vm.provider == ProviderType.HARVESTER
|
||||
assert vm.platform_category == PlatformCategory.HCI
|
||||
assert vm.architecture == CpuArchitecture.AMD64
|
||||
assert vm.attributes["running"] is True
|
||||
assert "volume:test-disk" in vm.raw_references
|
||||
assert "network:vlan100" in vm.raw_references
|
||||
|
||||
def test_discover_volumes(self):
|
||||
"""discover_resources discovers data volumes."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
mock_custom_api.list_cluster_custom_object.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "test-disk",
|
||||
"namespace": "default",
|
||||
"uid": "vol-uid-456",
|
||||
"labels": {},
|
||||
},
|
||||
"spec": {
|
||||
"source": {"http": {"url": "https://images.example.com/disk.img"}},
|
||||
"pvc": {"accessModes": ["ReadWriteOnce"], "resources": {"requests": {"storage": "10Gi"}}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_volume"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
vol = result.resources[0]
|
||||
assert vol.resource_type == "harvester_volume"
|
||||
assert vol.name == "test-disk"
|
||||
assert vol.unique_id == "vol-uid-456"
|
||||
|
||||
def test_discover_images(self):
|
||||
"""discover_resources discovers VM images."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
mock_custom_api.list_cluster_custom_object.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "ubuntu-22.04",
|
||||
"namespace": "default",
|
||||
"uid": "img-uid-789",
|
||||
"labels": {"os": "linux"},
|
||||
},
|
||||
"spec": {
|
||||
"displayName": "Ubuntu 22.04 LTS",
|
||||
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_image"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
img = result.resources[0]
|
||||
assert img.resource_type == "harvester_image"
|
||||
assert img.name == "ubuntu-22.04"
|
||||
assert img.attributes["display_name"] == "Ubuntu 22.04 LTS"
|
||||
assert "ubuntu.com" in img.attributes["url"]
|
||||
|
||||
def test_discover_networks(self):
|
||||
"""discover_resources discovers network attachment definitions."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
mock_custom_api.list_cluster_custom_object.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"name": "vlan100",
|
||||
"namespace": "default",
|
||||
"uid": "net-uid-abc",
|
||||
"labels": {},
|
||||
},
|
||||
"spec": {
|
||||
"config": '{"cniVersion":"0.3.1","name":"vlan100","type":"bridge"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_network"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
net = result.resources[0]
|
||||
assert net.resource_type == "harvester_network"
|
||||
assert net.name == "vlan100"
|
||||
assert "vlan100" in net.attributes["config"]
|
||||
|
||||
def test_discover_multiple_resource_types(self):
|
||||
"""discover_resources handles multiple resource types in one call."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
# Return different items based on the CRD being queried
|
||||
def mock_list_custom_object(group, version, plural):
|
||||
if plural == "virtualmachines":
|
||||
return {"items": [{"metadata": {"name": "vm1", "namespace": "default", "uid": "uid-1", "labels": {}, "annotations": {}}, "spec": {"running": True, "template": {"spec": {}}}}]}
|
||||
elif plural == "network-attachment-definitions":
|
||||
return {"items": [{"metadata": {"name": "net1", "namespace": "default", "uid": "uid-2", "labels": {}}, "spec": {"config": "{}"}}]}
|
||||
return {"items": []}
|
||||
|
||||
mock_custom_api.list_cluster_custom_object.side_effect = mock_list_custom_object
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_virtualmachine", "harvester_network"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
types = {r.resource_type for r in result.resources}
|
||||
assert types == {"harvester_virtualmachine", "harvester_network"}
|
||||
|
||||
def test_discover_resources_api_error(self):
|
||||
"""discover_resources records errors when API calls fail."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
mock_custom_api.list_cluster_custom_object.side_effect = ApiException(
|
||||
status=403, reason="Forbidden"
|
||||
)
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_virtualmachine"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "403" in result.errors[0]
|
||||
|
||||
def test_discover_resources_progress_callback(self):
|
||||
"""discover_resources invokes progress_callback correctly."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
mock_custom_api.list_cluster_custom_object.return_value = {"items": []}
|
||||
|
||||
progress_updates: list[ScanProgress] = []
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_virtualmachine", "harvester_volume"],
|
||||
progress_callback=lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
# Should have progress updates: one per resource type + final
|
||||
assert len(progress_updates) == 3
|
||||
assert progress_updates[0].current_resource_type == "harvester_virtualmachine"
|
||||
assert progress_updates[0].total_resource_types == 2
|
||||
assert progress_updates[-1].resource_types_completed == 2
|
||||
|
||||
def test_discover_resources_unknown_type_warning(self):
|
||||
"""discover_resources warns about unknown resource types."""
|
||||
plugin, mock_custom_api, _ = _make_authenticated_plugin()
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://harvester.local:6443"],
|
||||
resource_types=["harvester_unknown"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.warnings) == 1
|
||||
assert "Unknown resource type" in result.warnings[0]
|
||||
|
||||
|
||||
class TestHarvesterPluginVMReferences:
|
||||
"""Tests for VM reference extraction."""
|
||||
|
||||
def test_extract_vm_references_data_volume(self):
|
||||
"""Extracts dataVolume references from VM spec."""
|
||||
spec = {
|
||||
"template": {
|
||||
"spec": {
|
||||
"volumes": [
|
||||
{"dataVolume": {"name": "my-disk"}},
|
||||
],
|
||||
"networks": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
refs = HarvesterPlugin._extract_vm_references(spec)
|
||||
assert refs == ["volume:my-disk"]
|
||||
|
||||
def test_extract_vm_references_pvc(self):
|
||||
"""Extracts persistentVolumeClaim references from VM spec."""
|
||||
spec = {
|
||||
"template": {
|
||||
"spec": {
|
||||
"volumes": [
|
||||
{"persistentVolumeClaim": {"claimName": "my-pvc"}},
|
||||
],
|
||||
"networks": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
refs = HarvesterPlugin._extract_vm_references(spec)
|
||||
assert refs == ["volume:my-pvc"]
|
||||
|
||||
def test_extract_vm_references_multus_network(self):
|
||||
"""Extracts multus network references from VM spec."""
|
||||
spec = {
|
||||
"template": {
|
||||
"spec": {
|
||||
"volumes": [],
|
||||
"networks": [
|
||||
{"multus": {"networkName": "vlan200"}},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
refs = HarvesterPlugin._extract_vm_references(spec)
|
||||
assert refs == ["network:vlan200"]
|
||||
|
||||
def test_extract_vm_references_empty_spec(self):
|
||||
"""Returns empty list for empty spec."""
|
||||
refs = HarvesterPlugin._extract_vm_references({})
|
||||
assert refs == []
|
||||
|
||||
def test_extract_vm_references_mixed(self):
|
||||
"""Extracts both volume and network references."""
|
||||
spec = {
|
||||
"template": {
|
||||
"spec": {
|
||||
"volumes": [
|
||||
{"dataVolume": {"name": "disk-1"}},
|
||||
{"persistentVolumeClaim": {"claimName": "pvc-1"}},
|
||||
],
|
||||
"networks": [
|
||||
{"multus": {"networkName": "mgmt-net"}},
|
||||
{"multus": {"networkName": "data-net"}},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
refs = HarvesterPlugin._extract_vm_references(spec)
|
||||
assert refs == [
|
||||
"volume:disk-1",
|
||||
"volume:pvc-1",
|
||||
"network:mgmt-net",
|
||||
"network:data-net",
|
||||
]
|
||||
513
tests/unit/test_incremental_updater.py
Normal file
513
tests/unit/test_incremental_updater.py
Normal file
@@ -0,0 +1,513 @@
|
||||
"""Unit tests for the IncrementalUpdater class."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.incremental.incremental_updater import IncrementalUpdater
|
||||
from iac_reverse.models import ChangeSummary, ChangeType, ResourceChange
|
||||
|
||||
|
||||
def _make_change(
|
||||
resource_id: str = "res-1",
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
resource_name: str = "nginx",
|
||||
change_type: ChangeType = ChangeType.ADDED,
|
||||
changed_attributes: dict | None = None,
|
||||
) -> ResourceChange:
|
||||
"""Create a ResourceChange for testing."""
|
||||
return ResourceChange(
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type,
|
||||
resource_name=resource_name,
|
||||
change_type=change_type,
|
||||
changed_attributes=changed_attributes,
|
||||
)
|
||||
|
||||
|
||||
def _make_summary(changes: list[ResourceChange]) -> ChangeSummary:
|
||||
"""Create a ChangeSummary from a list of changes."""
|
||||
added = sum(1 for c in changes if c.change_type == ChangeType.ADDED)
|
||||
removed = sum(1 for c in changes if c.change_type == ChangeType.REMOVED)
|
||||
modified = sum(1 for c in changes if c.change_type == ChangeType.MODIFIED)
|
||||
return ChangeSummary(
|
||||
added_count=added,
|
||||
removed_count=removed,
|
||||
modified_count=modified,
|
||||
changes=changes,
|
||||
)
|
||||
|
||||
|
||||
def _write_tf_file(output_dir: Path, resource_type: str, content: str) -> Path:
|
||||
"""Write a .tf file to the output directory."""
|
||||
tf_file = output_dir / f"{resource_type}.tf"
|
||||
tf_file.write_text(content, encoding="utf-8")
|
||||
return tf_file
|
||||
|
||||
|
||||
def _write_state_file(output_dir: Path, resources: list[dict]) -> Path:
|
||||
"""Write a terraform.tfstate file to the output directory."""
|
||||
state = {
|
||||
"version": 4,
|
||||
"terraform_version": "1.7.0",
|
||||
"serial": 1,
|
||||
"lineage": "test-lineage-uuid",
|
||||
"outputs": {},
|
||||
"resources": resources,
|
||||
}
|
||||
state_file = output_dir / "terraform.tfstate"
|
||||
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
return state_file
|
||||
|
||||
|
||||
class TestAddedResource:
|
||||
"""Tests for adding new resource blocks."""
|
||||
|
||||
def test_added_resource_creates_block_in_correct_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An ADDED resource creates a new block in the resource type .tf file."""
|
||||
change = _make_change(
|
||||
resource_id="apps/v1/deployments/default/nginx",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
attributes = {
|
||||
"apps/v1/deployments/default/nginx": {
|
||||
"namespace": "default",
|
||||
"replicas": 3,
|
||||
}
|
||||
}
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path), attributes)
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
assert tf_file.exists()
|
||||
content = tf_file.read_text(encoding="utf-8")
|
||||
assert 'resource "kubernetes_deployment" "nginx"' in content
|
||||
assert "namespace" in content
|
||||
assert "replicas" in content
|
||||
assert "# Source: apps/v1/deployments/default/nginx" in content
|
||||
|
||||
def test_added_resource_appends_to_existing_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An ADDED resource appends to an existing .tf file without overwriting."""
|
||||
existing_content = (
|
||||
'# Source: existing-id\n'
|
||||
'resource "kubernetes_deployment" "existing" {\n'
|
||||
' replicas = 1\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", existing_content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="new-id",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="new-service",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
attributes = {"new-id": {"replicas": 2}}
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path), attributes)
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
content = tf_file.read_text(encoding="utf-8")
|
||||
# Both resources should be present
|
||||
assert 'resource "kubernetes_deployment" "existing"' in content
|
||||
assert 'resource "kubernetes_deployment" "new_service"' in content
|
||||
|
||||
def test_added_resource_creates_file_if_not_exists(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An ADDED resource creates the .tf file if it doesn't exist."""
|
||||
change = _make_change(
|
||||
resource_id="svc-1",
|
||||
resource_type="docker_service",
|
||||
resource_name="my-app",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
attributes = {"svc-1": {"image": "nginx:latest"}}
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path), attributes)
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "docker_service.tf"
|
||||
assert tf_file.exists()
|
||||
content = tf_file.read_text(encoding="utf-8")
|
||||
assert 'resource "docker_service" "my_app"' in content
|
||||
|
||||
|
||||
class TestRemovedResource:
|
||||
"""Tests for removing resource blocks."""
|
||||
|
||||
def test_removed_resource_removes_block_from_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A REMOVED resource removes its block from the .tf file."""
|
||||
content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
'}\n'
|
||||
'\n'
|
||||
'# Source: res-2\n'
|
||||
'resource "kubernetes_deployment" "redis" {\n'
|
||||
' replicas = 1\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.REMOVED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
result = tf_file.read_text(encoding="utf-8")
|
||||
assert "nginx" not in result
|
||||
assert 'resource "kubernetes_deployment" "redis"' in result
|
||||
|
||||
def test_removed_resource_updates_state_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A REMOVED resource removes its entry from the state file."""
|
||||
state_resources = [
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "kubernetes_deployment",
|
||||
"name": "nginx",
|
||||
"provider": 'provider["registry.terraform.io/hashicorp/kubernetes"]',
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {"id": "res-1", "replicas": 3},
|
||||
"sensitive_attributes": [],
|
||||
"dependencies": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "kubernetes_deployment",
|
||||
"name": "redis",
|
||||
"provider": 'provider["registry.terraform.io/hashicorp/kubernetes"]',
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {"id": "res-2", "replicas": 1},
|
||||
"sensitive_attributes": [],
|
||||
"dependencies": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
_write_state_file(tmp_path, state_resources)
|
||||
|
||||
# Also write the .tf file so the removal can proceed
|
||||
tf_content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", tf_content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.REMOVED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
state_file = tmp_path / "terraform.tfstate"
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
# Only redis should remain
|
||||
assert len(state["resources"]) == 1
|
||||
assert state["resources"][0]["name"] == "redis"
|
||||
# Serial should be incremented
|
||||
assert state["serial"] == 2
|
||||
|
||||
|
||||
class TestModifiedResource:
|
||||
"""Tests for updating resource blocks."""
|
||||
|
||||
def test_modified_resource_updates_block_in_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A MODIFIED resource updates the attribute values in the .tf file."""
|
||||
content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
' image = "nginx:1.24"\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.MODIFIED,
|
||||
changed_attributes={
|
||||
"replicas": {"old": 3, "new": 5},
|
||||
},
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
result = tf_file.read_text(encoding="utf-8")
|
||||
assert "replicas = 5" in result
|
||||
assert "replicas = 3" not in result
|
||||
# Unchanged attribute should remain
|
||||
assert 'image = "nginx:1.24"' in result
|
||||
|
||||
def test_modified_resource_adds_new_attribute(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A MODIFIED resource with a new attribute adds it to the block."""
|
||||
content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.MODIFIED,
|
||||
changed_attributes={
|
||||
"image": {"old": None, "new": "nginx:1.25"},
|
||||
},
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
result = tf_file.read_text(encoding="utf-8")
|
||||
assert '"nginx:1.25"' in result
|
||||
assert "replicas = 3" in result
|
||||
|
||||
def test_modified_resource_removes_attribute(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A MODIFIED resource with a removed attribute removes the line."""
|
||||
content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
' image = "nginx:1.24"\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.MODIFIED,
|
||||
changed_attributes={
|
||||
"image": {"old": "nginx:1.24", "new": None},
|
||||
},
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
tf_file = tmp_path / "kubernetes_deployment.tf"
|
||||
result = tf_file.read_text(encoding="utf-8")
|
||||
assert "image" not in result
|
||||
assert "replicas = 3" in result
|
||||
|
||||
|
||||
class TestOnlyAffectedFilesModified:
|
||||
"""Tests that only files with changed resources are modified."""
|
||||
|
||||
def test_unrelated_files_are_not_modified(self, tmp_path: Path) -> None:
|
||||
"""Files for resource types without changes are not touched."""
|
||||
# Write two .tf files
|
||||
k8s_content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
'}\n'
|
||||
)
|
||||
docker_content = (
|
||||
'# Source: svc-1\n'
|
||||
'resource "docker_service" "app" {\n'
|
||||
' image = "app:latest"\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", k8s_content)
|
||||
docker_file = _write_tf_file(tmp_path, "docker_service", docker_content)
|
||||
|
||||
# Record the modification time of the docker file
|
||||
docker_mtime_before = docker_file.stat().st_mtime
|
||||
|
||||
# Only change the kubernetes resource
|
||||
change = _make_change(
|
||||
resource_id="res-1",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.MODIFIED,
|
||||
changed_attributes={"replicas": {"old": 3, "new": 5}},
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
# Docker file should not be in modified_files
|
||||
assert str(docker_file) not in updater.modified_files
|
||||
# Kubernetes file should be in modified_files
|
||||
k8s_file = tmp_path / "kubernetes_deployment.tf"
|
||||
assert str(k8s_file) in updater.modified_files
|
||||
|
||||
def test_modified_files_tracks_only_changed(self, tmp_path: Path) -> None:
|
||||
"""The modified_files property only contains files that were changed."""
|
||||
content = (
|
||||
'# Source: res-1\n'
|
||||
'resource "kubernetes_deployment" "nginx" {\n'
|
||||
' replicas = 3\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "kubernetes_deployment", content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="new-id",
|
||||
resource_type="docker_service",
|
||||
resource_name="new-svc",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
attributes = {"new-id": {"image": "app:1.0"}}
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path), attributes)
|
||||
updater.apply()
|
||||
|
||||
# Only docker_service.tf should be modified
|
||||
modified = updater.modified_files
|
||||
assert any("docker_service.tf" in f for f in modified)
|
||||
assert not any("kubernetes_deployment.tf" in f for f in modified)
|
||||
|
||||
|
||||
class TestStateFileUpdatedForRemovedResources:
|
||||
"""Tests that state file is properly updated when resources are removed."""
|
||||
|
||||
def test_state_entry_removed_for_removed_resource(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Removing a resource also removes its state entry."""
|
||||
state_resources = [
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "docker_service",
|
||||
"name": "my_app",
|
||||
"provider": 'provider["registry.terraform.io/hashicorp/docker"]',
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {"id": "svc-1", "image": "app:1.0"},
|
||||
"sensitive_attributes": [],
|
||||
"dependencies": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
_write_state_file(tmp_path, state_resources)
|
||||
|
||||
tf_content = (
|
||||
'# Source: svc-1\n'
|
||||
'resource "docker_service" "my_app" {\n'
|
||||
' image = "app:1.0"\n'
|
||||
'}\n'
|
||||
)
|
||||
_write_tf_file(tmp_path, "docker_service", tf_content)
|
||||
|
||||
change = _make_change(
|
||||
resource_id="svc-1",
|
||||
resource_type="docker_service",
|
||||
resource_name="my-app",
|
||||
change_type=ChangeType.REMOVED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path))
|
||||
updater.apply()
|
||||
|
||||
state_file = tmp_path / "terraform.tfstate"
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert len(state["resources"]) == 0
|
||||
assert state["serial"] == 2
|
||||
|
||||
def test_state_file_not_modified_when_no_removals(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""State file is not modified when there are no REMOVED changes."""
|
||||
state_resources = [
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "kubernetes_deployment",
|
||||
"name": "nginx",
|
||||
"provider": 'provider["registry.terraform.io/hashicorp/kubernetes"]',
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {"id": "res-1"},
|
||||
"sensitive_attributes": [],
|
||||
"dependencies": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
_write_state_file(tmp_path, state_resources)
|
||||
|
||||
# Only an ADDED change (no removal)
|
||||
change = _make_change(
|
||||
resource_id="new-id",
|
||||
resource_type="docker_service",
|
||||
resource_name="new-svc",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
summary = _make_summary([change])
|
||||
attributes = {"new-id": {"image": "app:1.0"}}
|
||||
|
||||
updater = IncrementalUpdater(summary, str(tmp_path), attributes)
|
||||
updater.apply()
|
||||
|
||||
# State file should not be in modified_files
|
||||
state_path = str(tmp_path / "terraform.tfstate")
|
||||
assert state_path not in updater.modified_files
|
||||
|
||||
# State should still have the original entry
|
||||
state_file = tmp_path / "terraform.tfstate"
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert len(state["resources"]) == 1
|
||||
assert state["serial"] == 1
|
||||
508
tests/unit/test_kubernetes_plugin.py
Normal file
508
tests/unit/test_kubernetes_plugin.py
Normal file
@@ -0,0 +1,508 @@
|
||||
"""Unit tests for the KubernetesPlugin provider plugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.scanner.kubernetes_plugin import KubernetesPlugin
|
||||
from iac_reverse.scanner.scanner import AuthenticationError
|
||||
|
||||
|
||||
class TestKubernetesPluginAuthenticate:
|
||||
"""Tests for KubernetesPlugin.authenticate()."""
|
||||
|
||||
@patch("iac_reverse.scanner.kubernetes_plugin.config")
|
||||
@patch("iac_reverse.scanner.kubernetes_plugin.client")
|
||||
def test_authenticate_with_kubeconfig_path(self, mock_client, mock_config):
|
||||
"""Successfully authenticates with a kubeconfig path."""
|
||||
plugin = KubernetesPlugin()
|
||||
credentials = {"kubeconfig_path": "/home/user/.kube/config"}
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
|
||||
mock_config.load_kube_config.assert_called_once_with(
|
||||
config_file="/home/user/.kube/config",
|
||||
context=None,
|
||||
)
|
||||
mock_client.ApiClient.assert_called_once()
|
||||
assert plugin._core_v1 is not None
|
||||
assert plugin._apps_v1 is not None
|
||||
assert plugin._networking_v1 is not None
|
||||
|
||||
@patch("iac_reverse.scanner.kubernetes_plugin.config")
|
||||
@patch("iac_reverse.scanner.kubernetes_plugin.client")
|
||||
def test_authenticate_with_context(self, mock_client, mock_config):
|
||||
"""Authenticates with a specific context."""
|
||||
plugin = KubernetesPlugin()
|
||||
credentials = {
|
||||
"kubeconfig_path": "/home/user/.kube/config",
|
||||
"context": "production",
|
||||
}
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
|
||||
mock_config.load_kube_config.assert_called_once_with(
|
||||
config_file="/home/user/.kube/config",
|
||||
context="production",
|
||||
)
|
||||
|
||||
def test_authenticate_missing_kubeconfig_path(self):
|
||||
"""Raises AuthenticationError when kubeconfig_path is missing."""
|
||||
plugin = KubernetesPlugin()
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({})
|
||||
|
||||
assert "kubeconfig_path is required" in str(exc_info.value)
|
||||
|
||||
@patch("iac_reverse.scanner.kubernetes_plugin.config")
|
||||
def test_authenticate_invalid_kubeconfig(self, mock_config):
|
||||
"""Raises AuthenticationError when kubeconfig is invalid."""
|
||||
mock_config.load_kube_config.side_effect = Exception("file not found")
|
||||
plugin = KubernetesPlugin()
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"kubeconfig_path": "/invalid/path"})
|
||||
|
||||
assert "Failed to load kubeconfig" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestKubernetesPluginPlatformCategory:
|
||||
"""Tests for KubernetesPlugin.get_platform_category()."""
|
||||
|
||||
def test_returns_container_orchestration(self):
|
||||
"""Returns CONTAINER_ORCHESTRATION category."""
|
||||
plugin = KubernetesPlugin()
|
||||
assert plugin.get_platform_category() == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
|
||||
class TestKubernetesPluginSupportedResourceTypes:
|
||||
"""Tests for KubernetesPlugin.list_supported_resource_types()."""
|
||||
|
||||
def test_returns_all_kubernetes_resource_types(self):
|
||||
"""Returns all six supported Kubernetes resource types."""
|
||||
plugin = KubernetesPlugin()
|
||||
types = plugin.list_supported_resource_types()
|
||||
|
||||
expected = [
|
||||
"kubernetes_deployment",
|
||||
"kubernetes_service",
|
||||
"kubernetes_ingress",
|
||||
"kubernetes_config_map",
|
||||
"kubernetes_persistent_volume",
|
||||
"kubernetes_namespace",
|
||||
]
|
||||
assert types == expected
|
||||
|
||||
def test_returns_new_list_each_call(self):
|
||||
"""Returns a new list instance each call (not mutable reference)."""
|
||||
plugin = KubernetesPlugin()
|
||||
types1 = plugin.list_supported_resource_types()
|
||||
types2 = plugin.list_supported_resource_types()
|
||||
assert types1 == types2
|
||||
assert types1 is not types2
|
||||
|
||||
|
||||
class TestKubernetesPluginDetectArchitecture:
|
||||
"""Tests for KubernetesPlugin.detect_architecture()."""
|
||||
|
||||
def test_detects_amd64_from_node_label(self):
|
||||
"""Detects AMD64 architecture from kubernetes.io/arch label."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "192.168.1.10")],
|
||||
labels={"kubernetes.io/arch": "amd64"},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.10")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
def test_detects_arm64_from_node_label(self):
|
||||
"""Detects AARCH64 architecture from arm64 label."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "192.168.1.20")],
|
||||
labels={"kubernetes.io/arch": "arm64"},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.20")
|
||||
assert result == CpuArchitecture.AARCH64
|
||||
|
||||
def test_detects_arm_from_node_label(self):
|
||||
"""Detects ARM architecture from arm label."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "192.168.1.30")],
|
||||
labels={"kubernetes.io/arch": "arm"},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.30")
|
||||
assert result == CpuArchitecture.ARM
|
||||
|
||||
def test_falls_back_to_beta_label(self):
|
||||
"""Falls back to beta.kubernetes.io/arch label."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "192.168.1.40")],
|
||||
labels={"beta.kubernetes.io/arch": "arm64"},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.40")
|
||||
assert result == CpuArchitecture.AARCH64
|
||||
|
||||
def test_defaults_to_amd64_when_no_label(self):
|
||||
"""Defaults to AMD64 when no arch label is present."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "192.168.1.50")],
|
||||
labels={},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.50")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
def test_defaults_to_amd64_when_not_authenticated(self):
|
||||
"""Returns AMD64 when plugin is not authenticated."""
|
||||
plugin = KubernetesPlugin()
|
||||
result = plugin.detect_architecture("192.168.1.1")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
def test_defaults_to_amd64_on_api_error(self):
|
||||
"""Returns AMD64 when API call fails."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
plugin._core_v1.list_node.side_effect = Exception("API error")
|
||||
|
||||
result = plugin.detect_architecture("192.168.1.1")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
class TestKubernetesPluginListEndpoints:
|
||||
"""Tests for KubernetesPlugin.list_endpoints()."""
|
||||
|
||||
def test_returns_node_internal_ips(self):
|
||||
"""Returns InternalIP addresses from nodes."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._core_v1 = MagicMock()
|
||||
|
||||
nodes = [
|
||||
_make_node(addresses=[("InternalIP", "10.0.0.1")]),
|
||||
_make_node(addresses=[("InternalIP", "10.0.0.2")]),
|
||||
]
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=nodes)
|
||||
|
||||
result = plugin.list_endpoints()
|
||||
assert result == ["10.0.0.1", "10.0.0.2"]
|
||||
|
||||
def test_returns_empty_when_not_authenticated(self):
|
||||
"""Returns empty list when not authenticated."""
|
||||
plugin = KubernetesPlugin()
|
||||
assert plugin.list_endpoints() == []
|
||||
|
||||
|
||||
class TestKubernetesPluginDiscoverResources:
|
||||
"""Tests for KubernetesPlugin.discover_resources()."""
|
||||
|
||||
def test_discovers_deployments(self):
|
||||
"""Discovers deployments and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
dep = MagicMock()
|
||||
dep.metadata.name = "nginx"
|
||||
dep.metadata.namespace = "default"
|
||||
dep.metadata.labels = {"app": "nginx"}
|
||||
dep.spec.replicas = 3
|
||||
plugin._apps_v1.list_deployment_for_all_namespaces.return_value = MagicMock(
|
||||
items=[dep]
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="deployments")
|
||||
|
||||
progress_updates = []
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_deployment"],
|
||||
progress_callback=progress_updates.append,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "kubernetes_deployment"
|
||||
assert resource.unique_id == "default/nginx"
|
||||
assert resource.name == "nginx"
|
||||
assert resource.provider == ProviderType.KUBERNETES
|
||||
assert resource.platform_category == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert resource.attributes["namespace"] == "default"
|
||||
assert resource.attributes["replicas"] == 3
|
||||
|
||||
def test_discovers_services(self):
|
||||
"""Discovers services and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
svc = MagicMock()
|
||||
svc.metadata.name = "my-service"
|
||||
svc.metadata.namespace = "production"
|
||||
svc.metadata.labels = {"app": "web"}
|
||||
svc.spec.type = "ClusterIP"
|
||||
svc.spec.cluster_ip = "10.96.0.1"
|
||||
plugin._core_v1.list_service_for_all_namespaces.return_value = MagicMock(
|
||||
items=[svc]
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="services")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_service"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "kubernetes_service"
|
||||
assert resource.unique_id == "production/my-service"
|
||||
assert resource.attributes["type"] == "ClusterIP"
|
||||
|
||||
def test_discovers_ingresses(self):
|
||||
"""Discovers ingresses and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
ing = MagicMock()
|
||||
ing.metadata.name = "web-ingress"
|
||||
ing.metadata.namespace = "default"
|
||||
ing.metadata.labels = {"app": "web"}
|
||||
plugin._networking_v1.list_ingress_for_all_namespaces.return_value = MagicMock(
|
||||
items=[ing]
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="ingresses")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_ingress"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "kubernetes_ingress"
|
||||
assert result.resources[0].unique_id == "default/web-ingress"
|
||||
|
||||
def test_discovers_config_maps(self):
|
||||
"""Discovers config maps and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
cm = MagicMock()
|
||||
cm.metadata.name = "app-config"
|
||||
cm.metadata.namespace = "default"
|
||||
cm.metadata.labels = {}
|
||||
cm.data = {"key1": "value1", "key2": "value2"}
|
||||
plugin._core_v1.list_config_map_for_all_namespaces.return_value = MagicMock(
|
||||
items=[cm]
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="config_maps")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_config_map"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "kubernetes_config_map"
|
||||
assert resource.attributes["data_keys"] == ["key1", "key2"]
|
||||
|
||||
def test_discovers_persistent_volumes(self):
|
||||
"""Discovers persistent volumes and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
pv = MagicMock()
|
||||
pv.metadata.name = "pv-data"
|
||||
pv.metadata.labels = {}
|
||||
pv.spec.capacity = {"storage": "10Gi"}
|
||||
pv.spec.access_modes = ["ReadWriteOnce"]
|
||||
pv.spec.storage_class_name = "standard"
|
||||
plugin._core_v1.list_persistent_volume.return_value = MagicMock(
|
||||
items=[pv]
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="persistent_volumes")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_persistent_volume"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "kubernetes_persistent_volume"
|
||||
assert resource.unique_id == "pv-data"
|
||||
assert resource.attributes["capacity"] == {"storage": "10Gi"}
|
||||
assert resource.attributes["access_modes"] == ["ReadWriteOnce"]
|
||||
|
||||
def test_discovers_namespaces(self):
|
||||
"""Discovers namespaces and returns DiscoveredResource objects."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
|
||||
ns = MagicMock()
|
||||
ns.metadata.name = "production"
|
||||
ns.metadata.labels = {"env": "prod"}
|
||||
ns.status.phase = "Active"
|
||||
plugin._core_v1.list_namespace.return_value = MagicMock(items=[ns])
|
||||
_stub_empty_apis(plugin, exclude="namespaces")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_namespace"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
resource = result.resources[0]
|
||||
assert resource.resource_type == "kubernetes_namespace"
|
||||
assert resource.unique_id == "production"
|
||||
assert resource.attributes["status"] == "Active"
|
||||
|
||||
def test_reports_progress_for_each_resource_type(self):
|
||||
"""Reports progress callback for each resource type scanned."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
_stub_empty_apis(plugin)
|
||||
|
||||
progress_updates: list[ScanProgress] = []
|
||||
plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_deployment", "kubernetes_service"],
|
||||
progress_callback=progress_updates.append,
|
||||
)
|
||||
|
||||
assert len(progress_updates) == 2
|
||||
assert progress_updates[0].current_resource_type == "kubernetes_deployment"
|
||||
assert progress_updates[0].resource_types_completed == 1
|
||||
assert progress_updates[0].total_resource_types == 2
|
||||
assert progress_updates[1].current_resource_type == "kubernetes_service"
|
||||
assert progress_updates[1].resource_types_completed == 2
|
||||
|
||||
def test_handles_api_errors_gracefully(self):
|
||||
"""Records errors when API calls fail without crashing."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
plugin._apps_v1.list_deployment_for_all_namespaces.side_effect = Exception(
|
||||
"API unavailable"
|
||||
)
|
||||
_stub_empty_apis(plugin, exclude="deployments")
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_deployment"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert "API unavailable" in result.errors[0]
|
||||
assert len(result.resources) == 0
|
||||
|
||||
def test_returns_scan_result_type(self):
|
||||
"""Returns a ScanResult instance."""
|
||||
plugin = _make_authenticated_plugin()
|
||||
_stub_empty_apis(plugin)
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["10.0.0.1"],
|
||||
resource_types=["kubernetes_namespace"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert isinstance(result, ScanResult)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_node(
|
||||
addresses: list[tuple[str, str]] | None = None,
|
||||
labels: dict[str, str] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock Kubernetes node object."""
|
||||
node = MagicMock()
|
||||
node.metadata.labels = labels or {}
|
||||
|
||||
if addresses:
|
||||
addr_objects = []
|
||||
for addr_type, addr_value in addresses:
|
||||
addr = MagicMock()
|
||||
addr.type = addr_type
|
||||
addr.address = addr_value
|
||||
addr_objects.append(addr)
|
||||
node.status.addresses = addr_objects
|
||||
else:
|
||||
node.status.addresses = []
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def _make_authenticated_plugin() -> KubernetesPlugin:
|
||||
"""Create a KubernetesPlugin with mocked API clients."""
|
||||
plugin = KubernetesPlugin()
|
||||
plugin._api_client = MagicMock()
|
||||
plugin._core_v1 = MagicMock()
|
||||
plugin._apps_v1 = MagicMock()
|
||||
plugin._networking_v1 = MagicMock()
|
||||
|
||||
# Default: detect_architecture returns AMD64
|
||||
node = _make_node(
|
||||
addresses=[("InternalIP", "10.0.0.1")],
|
||||
labels={"kubernetes.io/arch": "amd64"},
|
||||
)
|
||||
plugin._core_v1.list_node.return_value = MagicMock(items=[node])
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
def _stub_empty_apis(plugin: KubernetesPlugin, exclude: str = "") -> None:
|
||||
"""Stub all discovery API calls to return empty lists.
|
||||
|
||||
Args:
|
||||
plugin: The plugin instance with mocked clients.
|
||||
exclude: Resource type to exclude from stubbing (leave for test to set up).
|
||||
"""
|
||||
if exclude != "deployments":
|
||||
plugin._apps_v1.list_deployment_for_all_namespaces.return_value = MagicMock(
|
||||
items=[]
|
||||
)
|
||||
if exclude != "services":
|
||||
plugin._core_v1.list_service_for_all_namespaces.return_value = MagicMock(
|
||||
items=[]
|
||||
)
|
||||
if exclude != "ingresses":
|
||||
plugin._networking_v1.list_ingress_for_all_namespaces.return_value = MagicMock(
|
||||
items=[]
|
||||
)
|
||||
if exclude != "config_maps":
|
||||
plugin._core_v1.list_config_map_for_all_namespaces.return_value = MagicMock(
|
||||
items=[]
|
||||
)
|
||||
if exclude != "persistent_volumes":
|
||||
plugin._core_v1.list_persistent_volume.return_value = MagicMock(items=[])
|
||||
if exclude != "namespaces":
|
||||
plugin._core_v1.list_namespace.return_value = MagicMock(items=[])
|
||||
403
tests/unit/test_models.py
Normal file
403
tests/unit/test_models.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Unit tests for core data models."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
ChangeType,
|
||||
ChangeSummary,
|
||||
CodeGenerationResult,
|
||||
CpuArchitecture,
|
||||
DependencyGraph,
|
||||
DiscoveredResource,
|
||||
ExtractedVariable,
|
||||
GeneratedFile,
|
||||
PlannedChange,
|
||||
PlatformCategory,
|
||||
PROVIDER_PLATFORM_MAP,
|
||||
ProviderType,
|
||||
ResourceChange,
|
||||
ResourceRelationship,
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
StateEntry,
|
||||
StateFile,
|
||||
UnresolvedReference,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
class TestProviderType:
|
||||
def test_all_values(self):
|
||||
assert ProviderType.DOCKER_SWARM.value == "docker_swarm"
|
||||
assert ProviderType.KUBERNETES.value == "kubernetes"
|
||||
assert ProviderType.SYNOLOGY.value == "synology"
|
||||
assert ProviderType.HARVESTER.value == "harvester"
|
||||
assert ProviderType.BARE_METAL.value == "bare_metal"
|
||||
assert ProviderType.WINDOWS.value == "windows"
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(ProviderType) == 6
|
||||
|
||||
|
||||
class TestPlatformCategory:
|
||||
def test_all_values(self):
|
||||
assert PlatformCategory.CONTAINER_ORCHESTRATION.value == "container"
|
||||
assert PlatformCategory.STORAGE_APPLIANCE.value == "storage"
|
||||
assert PlatformCategory.HCI.value == "hci"
|
||||
assert PlatformCategory.BARE_METAL.value == "bare_metal"
|
||||
assert PlatformCategory.WINDOWS.value == "windows"
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(PlatformCategory) == 5
|
||||
|
||||
|
||||
class TestProviderPlatformMap:
|
||||
def test_all_providers_mapped(self):
|
||||
for provider in ProviderType:
|
||||
assert provider in PROVIDER_PLATFORM_MAP
|
||||
|
||||
def test_container_orchestration_providers(self):
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.DOCKER_SWARM] == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.KUBERNETES] == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def test_other_providers(self):
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.SYNOLOGY] == PlatformCategory.STORAGE_APPLIANCE
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.HARVESTER] == PlatformCategory.HCI
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.BARE_METAL] == PlatformCategory.BARE_METAL
|
||||
assert PROVIDER_PLATFORM_MAP[ProviderType.WINDOWS] == PlatformCategory.WINDOWS
|
||||
|
||||
|
||||
class TestCpuArchitecture:
|
||||
def test_all_values(self):
|
||||
assert CpuArchitecture.AMD64.value == "amd64"
|
||||
assert CpuArchitecture.ARM.value == "arm"
|
||||
assert CpuArchitecture.AARCH64.value == "aarch64"
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(CpuArchitecture) == 3
|
||||
|
||||
|
||||
class TestChangeType:
|
||||
def test_all_values(self):
|
||||
assert ChangeType.ADDED.value == "added"
|
||||
assert ChangeType.REMOVED.value == "removed"
|
||||
assert ChangeType.MODIFIED.value == "modified"
|
||||
|
||||
|
||||
class TestScanProfile:
|
||||
def test_valid_profile(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={"kubeconfig_path": "/home/user/.kube/config"},
|
||||
)
|
||||
assert profile.validate() == []
|
||||
|
||||
def test_empty_credentials_invalid(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={},
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert len(errors) > 0
|
||||
assert "credentials" in errors[0]
|
||||
|
||||
def test_platform_category_property(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
credentials={"host": "localhost"},
|
||||
)
|
||||
assert profile.platform_category == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def test_optional_fields_default_none(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
credentials={"host": "nas01"},
|
||||
)
|
||||
assert profile.endpoints is None
|
||||
assert profile.resource_type_filters is None
|
||||
assert profile.authentik_token is None
|
||||
|
||||
|
||||
class TestDiscoveredResource:
|
||||
def test_creation(self):
|
||||
resource = DiscoveredResource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="apps/v1/deployments/default/nginx",
|
||||
name="nginx",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api:6443",
|
||||
attributes={"replicas": 3, "image": "nginx:1.25"},
|
||||
raw_references=["default/services/nginx-svc"],
|
||||
)
|
||||
assert resource.resource_type == "kubernetes_deployment"
|
||||
assert resource.unique_id == "apps/v1/deployments/default/nginx"
|
||||
assert resource.provider == ProviderType.KUBERNETES
|
||||
|
||||
def test_raw_references_default_empty(self):
|
||||
resource = DiscoveredResource(
|
||||
resource_type="windows_service",
|
||||
unique_id="win01/services/nginx",
|
||||
name="nginx",
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
architecture=CpuArchitecture.AMD64,
|
||||
endpoint="win01.internal.lab",
|
||||
attributes={"state": "running"},
|
||||
)
|
||||
assert resource.raw_references == []
|
||||
|
||||
|
||||
class TestScanResult:
|
||||
def test_creation(self):
|
||||
result = ScanResult(
|
||||
resources=[],
|
||||
warnings=["unsupported type: foo"],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="abc123",
|
||||
)
|
||||
assert result.is_partial is False
|
||||
assert len(result.warnings) == 1
|
||||
|
||||
def test_partial_scan(self):
|
||||
result = ScanResult(
|
||||
resources=[],
|
||||
warnings=[],
|
||||
errors=["connection lost"],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="abc123",
|
||||
is_partial=True,
|
||||
)
|
||||
assert result.is_partial is True
|
||||
|
||||
|
||||
class TestScanProgress:
|
||||
def test_creation(self):
|
||||
progress = ScanProgress(
|
||||
current_resource_type="kubernetes_deployment",
|
||||
resources_discovered=15,
|
||||
resource_types_completed=2,
|
||||
total_resource_types=5,
|
||||
)
|
||||
assert progress.resources_discovered == 15
|
||||
assert progress.resource_types_completed == 2
|
||||
|
||||
|
||||
class TestResourceRelationship:
|
||||
def test_creation(self):
|
||||
rel = ResourceRelationship(
|
||||
source_id="resource-a",
|
||||
target_id="resource-b",
|
||||
relationship_type="dependency",
|
||||
source_attribute="network_id",
|
||||
)
|
||||
assert rel.relationship_type == "dependency"
|
||||
|
||||
|
||||
class TestUnresolvedReference:
|
||||
def test_creation(self):
|
||||
ref = UnresolvedReference(
|
||||
source_resource_id="resource-a",
|
||||
source_attribute="vpc_id",
|
||||
referenced_id="vpc-unknown",
|
||||
suggested_resolution="data_source",
|
||||
)
|
||||
assert ref.suggested_resolution == "data_source"
|
||||
|
||||
|
||||
class TestDependencyGraph:
|
||||
def test_creation(self):
|
||||
graph = DependencyGraph(
|
||||
resources=[],
|
||||
relationships=[],
|
||||
topological_order=["a", "b", "c"],
|
||||
cycles=[],
|
||||
unresolved_references=[],
|
||||
)
|
||||
assert graph.topological_order == ["a", "b", "c"]
|
||||
assert graph.cycles == []
|
||||
|
||||
|
||||
class TestGeneratedFile:
|
||||
def test_creation(self):
|
||||
gf = GeneratedFile(
|
||||
filename="kubernetes_deployment.tf",
|
||||
content='resource "kubernetes_deployment" "nginx" {}',
|
||||
resource_count=1,
|
||||
)
|
||||
assert gf.filename == "kubernetes_deployment.tf"
|
||||
assert gf.resource_count == 1
|
||||
|
||||
|
||||
class TestExtractedVariable:
|
||||
def test_creation(self):
|
||||
var = ExtractedVariable(
|
||||
name="environment",
|
||||
type_expr="string",
|
||||
default_value="production",
|
||||
description="Deployment environment",
|
||||
used_by=["resource-a", "resource-b"],
|
||||
)
|
||||
assert len(var.used_by) == 2
|
||||
|
||||
def test_used_by_default_empty(self):
|
||||
var = ExtractedVariable(
|
||||
name="region",
|
||||
type_expr="string",
|
||||
default_value="us-east-1",
|
||||
description="Region",
|
||||
)
|
||||
assert var.used_by == []
|
||||
|
||||
|
||||
class TestCodeGenerationResult:
|
||||
def test_creation(self):
|
||||
result = CodeGenerationResult(
|
||||
resource_files=[GeneratedFile("main.tf", "content", 5)],
|
||||
variables_file=GeneratedFile("variables.tf", "vars", 0),
|
||||
provider_file=GeneratedFile("provider.tf", "provider", 0),
|
||||
outputs_file=None,
|
||||
skipped_resources=[("res-1", "unsupported type")],
|
||||
)
|
||||
assert len(result.resource_files) == 1
|
||||
assert result.outputs_file is None
|
||||
assert len(result.skipped_resources) == 1
|
||||
|
||||
|
||||
class TestStateEntry:
|
||||
def test_creation(self):
|
||||
entry = StateEntry(
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
provider_id="apps/v1/deployments/default/nginx",
|
||||
attributes={"namespace": "default"},
|
||||
sensitive_attributes=["password"],
|
||||
schema_version=1,
|
||||
dependencies=["kubernetes_service.nginx_svc"],
|
||||
)
|
||||
assert entry.schema_version == 1
|
||||
assert len(entry.dependencies) == 1
|
||||
|
||||
def test_defaults(self):
|
||||
entry = StateEntry(
|
||||
resource_type="kubernetes_service",
|
||||
resource_name="svc",
|
||||
provider_id="id-123",
|
||||
attributes={},
|
||||
)
|
||||
assert entry.sensitive_attributes == []
|
||||
assert entry.schema_version == 0
|
||||
assert entry.dependencies == []
|
||||
|
||||
|
||||
class TestStateFile:
|
||||
def test_defaults(self):
|
||||
state = StateFile()
|
||||
assert state.version == 4
|
||||
assert state.terraform_version == ""
|
||||
assert state.serial == 1
|
||||
assert state.lineage == ""
|
||||
assert state.resources == []
|
||||
|
||||
def test_to_json_structure(self):
|
||||
state = StateFile(
|
||||
terraform_version="1.7.0",
|
||||
resources=[
|
||||
StateEntry(
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
provider_id="apps/v1/deployments/default/nginx",
|
||||
attributes={"namespace": "default", "replicas": 3},
|
||||
schema_version=1,
|
||||
dependencies=["kubernetes_service.nginx_svc"],
|
||||
)
|
||||
],
|
||||
)
|
||||
parsed = json.loads(state.to_json())
|
||||
assert parsed["version"] == 4
|
||||
assert parsed["terraform_version"] == "1.7.0"
|
||||
assert parsed["serial"] == 1
|
||||
assert "lineage" in parsed
|
||||
assert len(parsed["resources"]) == 1
|
||||
|
||||
res = parsed["resources"][0]
|
||||
assert res["mode"] == "managed"
|
||||
assert res["type"] == "kubernetes_deployment"
|
||||
assert res["name"] == "nginx"
|
||||
assert res["instances"][0]["schema_version"] == 1
|
||||
assert res["instances"][0]["attributes"]["id"] == "apps/v1/deployments/default/nginx"
|
||||
|
||||
def test_to_json_generates_lineage(self):
|
||||
state = StateFile()
|
||||
parsed = json.loads(state.to_json())
|
||||
assert len(parsed["lineage"]) > 0 # UUID generated
|
||||
|
||||
|
||||
class TestValidationResult:
|
||||
def test_creation(self):
|
||||
result = ValidationResult(
|
||||
init_success=True,
|
||||
validate_success=True,
|
||||
plan_success=False,
|
||||
planned_changes=[
|
||||
PlannedChange(
|
||||
resource_address="kubernetes_deployment.nginx",
|
||||
change_type="modify",
|
||||
details="replicas changed",
|
||||
)
|
||||
],
|
||||
errors=[],
|
||||
correction_attempts=1,
|
||||
)
|
||||
assert result.plan_success is False
|
||||
assert len(result.planned_changes) == 1
|
||||
|
||||
|
||||
class TestValidationError:
|
||||
def test_creation_with_line(self):
|
||||
err = ValidationError(file="main.tf", message="invalid block", line=42)
|
||||
assert err.line == 42
|
||||
|
||||
def test_creation_without_line(self):
|
||||
err = ValidationError(file="main.tf", message="missing provider")
|
||||
assert err.line is None
|
||||
|
||||
|
||||
class TestResourceChange:
|
||||
def test_added(self):
|
||||
change = ResourceChange(
|
||||
resource_id="new-resource",
|
||||
resource_type="kubernetes_service",
|
||||
resource_name="new-svc",
|
||||
change_type=ChangeType.ADDED,
|
||||
)
|
||||
assert change.changed_attributes is None
|
||||
|
||||
def test_modified(self):
|
||||
change = ResourceChange(
|
||||
resource_id="existing-resource",
|
||||
resource_type="kubernetes_deployment",
|
||||
resource_name="nginx",
|
||||
change_type=ChangeType.MODIFIED,
|
||||
changed_attributes={"replicas": {"old": 3, "new": 5}},
|
||||
)
|
||||
assert change.changed_attributes is not None
|
||||
|
||||
|
||||
class TestChangeSummary:
|
||||
def test_creation(self):
|
||||
summary = ChangeSummary(
|
||||
added_count=2,
|
||||
removed_count=1,
|
||||
modified_count=3,
|
||||
changes=[],
|
||||
)
|
||||
assert summary.added_count == 2
|
||||
assert summary.removed_count == 1
|
||||
assert summary.modified_count == 3
|
||||
505
tests/unit/test_multi_provider_scanner.py
Normal file
505
tests/unit/test_multi_provider_scanner.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""Unit tests for the MultiProviderScanner.
|
||||
|
||||
Tests cover:
|
||||
- All providers succeed: all resources collected
|
||||
- One provider fails: others still complete, failed one reported
|
||||
- Multiple providers fail: remaining still complete
|
||||
- Error details include provider name and reason
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
from iac_reverse.scanner.multi_provider_scanner import (
|
||||
MultiProviderScanner,
|
||||
MultiProviderScanResult,
|
||||
ProviderFailure,
|
||||
ProviderScanEntry,
|
||||
)
|
||||
from iac_reverse.scanner.scanner import AuthenticationError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_profile(provider: ProviderType = ProviderType.KUBERNETES) -> ScanProfile:
|
||||
"""Create a valid ScanProfile with sensible defaults."""
|
||||
return ScanProfile(
|
||||
provider=provider,
|
||||
credentials={"token": "test-token"},
|
||||
endpoints=["https://api.local:6443"],
|
||||
resource_type_filters=None,
|
||||
)
|
||||
|
||||
|
||||
def make_resource(
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
name: str = "nginx",
|
||||
) -> DiscoveredResource:
|
||||
"""Create a sample DiscoveredResource."""
|
||||
return DiscoveredResource(
|
||||
resource_type=resource_type,
|
||||
unique_id=f"{provider.value}/{resource_type}/{name}",
|
||||
name=name,
|
||||
provider=provider,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
endpoint="https://api.local:6443",
|
||||
attributes={"replicas": 3},
|
||||
raw_references=[],
|
||||
)
|
||||
|
||||
|
||||
class SuccessPlugin(ProviderPlugin):
|
||||
"""A plugin that always succeeds with configurable resources."""
|
||||
|
||||
def __init__(self, resources: list[DiscoveredResource] | None = None):
|
||||
self._resources = resources or []
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
pass
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return ["https://api.local:6443"]
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return ["kubernetes_deployment", "kubernetes_service"]
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AARCH64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback=None,
|
||||
) -> ScanResult:
|
||||
return ScanResult(
|
||||
resources=self._resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
|
||||
class FailingAuthPlugin(ProviderPlugin):
|
||||
"""A plugin that fails during authentication."""
|
||||
|
||||
def __init__(self, error_message: str = "Invalid credentials"):
|
||||
self._error_message = error_message
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
raise RuntimeError(self._error_message)
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return []
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback=None,
|
||||
) -> ScanResult:
|
||||
return ScanResult(
|
||||
resources=[],
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
|
||||
class FailingDiscoverPlugin(ProviderPlugin):
|
||||
"""A plugin that fails during resource discovery."""
|
||||
|
||||
def __init__(self, error_message: str = "Connection refused"):
|
||||
self._error_message = error_message
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
pass
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.STORAGE_APPLIANCE
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return ["https://nas.local:5001"]
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return ["synology_shared_folder"]
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback=None,
|
||||
) -> ScanResult:
|
||||
raise ConnectionError(self._error_message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: All providers succeed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllProvidersSucceed:
|
||||
"""Tests for the happy path where all providers complete successfully."""
|
||||
|
||||
def test_single_provider_all_resources_collected(self):
|
||||
resources = [
|
||||
make_resource(name="deploy-1"),
|
||||
make_resource(name="deploy-2"),
|
||||
]
|
||||
entry = ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=resources),
|
||||
)
|
||||
scanner = MultiProviderScanner([entry])
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert len(result.failed_providers) == 0
|
||||
assert "kubernetes" in result.successful_providers
|
||||
|
||||
def test_multiple_providers_all_resources_merged(self):
|
||||
k8s_resources = [
|
||||
make_resource(ProviderType.KUBERNETES, "kubernetes_deployment", "nginx"),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(ProviderType.DOCKER_SWARM, "docker_service", "web"),
|
||||
]
|
||||
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=k8s_resources),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.DOCKER_SWARM),
|
||||
plugin=SuccessPlugin(resources=docker_resources),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert len(result.failed_providers) == 0
|
||||
assert len(result.successful_providers) == 2
|
||||
|
||||
def test_empty_entries_returns_empty_result(self):
|
||||
scanner = MultiProviderScanner([])
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.failed_providers) == 0
|
||||
assert len(result.successful_providers) == 0
|
||||
|
||||
def test_scan_timestamp_is_set(self):
|
||||
entry = ProviderScanEntry(
|
||||
profile=make_profile(),
|
||||
plugin=SuccessPlugin(resources=[]),
|
||||
)
|
||||
scanner = MultiProviderScanner([entry])
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert result.scan_timestamp != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: One provider fails, others succeed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOneProviderFails:
|
||||
"""Tests for partial failure: one provider fails, others complete."""
|
||||
|
||||
def test_failed_provider_does_not_block_others(self):
|
||||
k8s_resources = [make_resource(ProviderType.KUBERNETES, name="nginx")]
|
||||
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=k8s_resources),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Invalid API key"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
# Kubernetes resources should still be collected
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].name == "nginx"
|
||||
# Synology should be reported as failed
|
||||
assert len(result.failed_providers) == 1
|
||||
assert result.failed_providers[0].provider_name == "synology"
|
||||
|
||||
def test_failed_provider_reported_with_error_details(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=[]),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Token expired at 2024-01-15"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
failure = result.failed_providers[0]
|
||||
assert failure.provider_name == "synology"
|
||||
assert "Token expired" in failure.error_message
|
||||
assert failure.error_type == "AuthenticationError"
|
||||
|
||||
def test_successful_providers_listed_correctly(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=[]),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.DOCKER_SWARM),
|
||||
plugin=FailingAuthPlugin("Connection refused"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert "kubernetes" in result.successful_providers
|
||||
assert "docker_swarm" not in result.successful_providers
|
||||
|
||||
def test_order_does_not_matter_failed_first(self):
|
||||
"""Even if the first provider fails, subsequent ones still run."""
|
||||
docker_resources = [make_resource(ProviderType.DOCKER_SWARM, "docker_service", "web")]
|
||||
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Auth failed"),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.DOCKER_SWARM),
|
||||
plugin=SuccessPlugin(resources=docker_resources),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].name == "web"
|
||||
assert len(result.failed_providers) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multiple providers fail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleProvidersFail:
|
||||
"""Tests for scenarios where multiple providers fail."""
|
||||
|
||||
def test_multiple_failures_remaining_still_complete(self):
|
||||
k8s_resources = [make_resource(ProviderType.KUBERNETES, name="app")]
|
||||
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Synology auth failed"),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=k8s_resources),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.HARVESTER),
|
||||
plugin=FailingAuthPlugin("Harvester unreachable"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].name == "app"
|
||||
assert len(result.failed_providers) == 2
|
||||
assert len(result.successful_providers) == 1
|
||||
|
||||
def test_all_providers_fail_returns_empty_resources(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Auth error 1"),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.HARVESTER),
|
||||
plugin=FailingAuthPlugin("Auth error 2"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.failed_providers) == 2
|
||||
assert len(result.successful_providers) == 0
|
||||
|
||||
def test_each_failure_has_distinct_error_details(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingAuthPlugin("Invalid API key"),
|
||||
),
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.HARVESTER),
|
||||
plugin=FailingAuthPlugin("Certificate expired"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
provider_names = [f.provider_name for f in result.failed_providers]
|
||||
assert "synology" in provider_names
|
||||
assert "harvester" in provider_names
|
||||
|
||||
synology_failure = next(
|
||||
f for f in result.failed_providers if f.provider_name == "synology"
|
||||
)
|
||||
harvester_failure = next(
|
||||
f for f in result.failed_providers if f.provider_name == "harvester"
|
||||
)
|
||||
assert "Invalid API key" in synology_failure.error_message
|
||||
assert "Certificate expired" in harvester_failure.error_message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Error details include provider name and reason
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorDetails:
|
||||
"""Tests that error details contain provider name and failure reason."""
|
||||
|
||||
def test_auth_error_includes_provider_name(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.DOCKER_SWARM),
|
||||
plugin=FailingAuthPlugin("Bad token"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert result.failed_providers[0].provider_name == "docker_swarm"
|
||||
|
||||
def test_auth_error_includes_reason(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.DOCKER_SWARM),
|
||||
plugin=FailingAuthPlugin("Token revoked by admin"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert "Token revoked by admin" in result.failed_providers[0].error_message
|
||||
|
||||
def test_connection_error_includes_error_type(self):
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.SYNOLOGY),
|
||||
plugin=FailingDiscoverPlugin("Connection timed out"),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
# ConnectionError is raised during discover; Scanner wraps it in
|
||||
# ConnectionLostError. The error_type reflects the wrapped exception.
|
||||
failure = result.failed_providers[0]
|
||||
assert failure.provider_name == "synology"
|
||||
assert failure.error_type == "ConnectionLostError"
|
||||
assert "Connection lost" in failure.error_message
|
||||
|
||||
def test_validation_error_includes_details(self):
|
||||
"""A provider with invalid profile still reports correctly."""
|
||||
# Create a profile with empty credentials to trigger ValueError
|
||||
bad_profile = ScanProfile(
|
||||
provider=ProviderType.BARE_METAL,
|
||||
credentials={},
|
||||
endpoints=["https://bmc.local"],
|
||||
)
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=bad_profile,
|
||||
plugin=SuccessPlugin(resources=[]),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
failure = result.failed_providers[0]
|
||||
assert failure.provider_name == "bare_metal"
|
||||
assert failure.error_type == "ValueError"
|
||||
assert "credentials" in failure.error_message.lower()
|
||||
|
||||
def test_progress_callback_invoked_for_successful_providers(self):
|
||||
"""Progress callback is passed through to individual scanners."""
|
||||
resources = [make_resource(name="test")]
|
||||
entries = [
|
||||
ProviderScanEntry(
|
||||
profile=make_profile(ProviderType.KUBERNETES),
|
||||
plugin=SuccessPlugin(resources=resources),
|
||||
),
|
||||
]
|
||||
scanner = MultiProviderScanner(entries)
|
||||
|
||||
progress_updates = []
|
||||
scanner.scan(progress_callback=progress_updates.append)
|
||||
|
||||
# SuccessPlugin doesn't call progress_callback, but the scan still works
|
||||
# This verifies the callback is accepted without error
|
||||
assert len(scanner.entries) == 1
|
||||
74
tests/unit/test_plugin_base.py
Normal file
74
tests/unit/test_plugin_base.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Unit tests for the ProviderPlugin abstract base class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
PlatformCategory,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
|
||||
|
||||
class TestProviderPluginInterface:
|
||||
def test_cannot_instantiate_directly(self):
|
||||
"""ProviderPlugin is abstract and cannot be instantiated."""
|
||||
with pytest.raises(TypeError):
|
||||
ProviderPlugin()
|
||||
|
||||
def test_requires_all_abstract_methods(self):
|
||||
"""A subclass must implement all abstract methods."""
|
||||
|
||||
class IncompletePlugin(ProviderPlugin):
|
||||
def authenticate(self, credentials):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompletePlugin()
|
||||
|
||||
def test_concrete_implementation(self):
|
||||
"""A complete implementation can be instantiated."""
|
||||
|
||||
class ConcretePlugin(ProviderPlugin):
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
pass
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return ["https://localhost:6443"]
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return ["kubernetes_deployment"]
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AMD64
|
||||
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback):
|
||||
return ScanResult(
|
||||
resources=[],
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-01T00:00:00Z",
|
||||
profile_hash="test",
|
||||
)
|
||||
|
||||
plugin = ConcretePlugin()
|
||||
assert plugin.get_platform_category() == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert plugin.list_endpoints() == ["https://localhost:6443"]
|
||||
assert plugin.list_supported_resource_types() == ["kubernetes_deployment"]
|
||||
assert plugin.detect_architecture("localhost") == CpuArchitecture.AMD64
|
||||
|
||||
def test_abstract_methods_list(self):
|
||||
"""Verify all expected abstract methods are defined."""
|
||||
expected_methods = {
|
||||
"authenticate",
|
||||
"get_platform_category",
|
||||
"list_endpoints",
|
||||
"list_supported_resource_types",
|
||||
"detect_architecture",
|
||||
"discover_resources",
|
||||
}
|
||||
assert ProviderPlugin.__abstractmethods__ == expected_methods
|
||||
324
tests/unit/test_profile_loader.py
Normal file
324
tests/unit/test_profile_loader.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""Unit tests for ProfileLoader - YAML scan profile loading with env var expansion."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.cli.profile_loader import ProfileLoader, ProfileLoaderError
|
||||
from iac_reverse.models import ProviderType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader():
|
||||
"""Create a ProfileLoader instance."""
|
||||
return ProfileLoader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_profile(tmp_path):
|
||||
"""Helper to write a YAML profile to a temp file and return its path."""
|
||||
|
||||
def _write(content: str) -> str:
|
||||
profile_file = tmp_path / "profile.yaml"
|
||||
profile_file.write_text(textwrap.dedent(content), encoding="utf-8")
|
||||
return str(profile_file)
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
class TestSingleProfileLoading:
|
||||
"""Tests for loading a single profile from YAML."""
|
||||
|
||||
def test_loads_single_profile(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
provider: kubernetes
|
||||
credentials:
|
||||
kubeconfig_path: /home/user/.kube/config
|
||||
context: pi-cluster
|
||||
endpoints:
|
||||
- https://k8s-api.internal.lab:6443
|
||||
resource_type_filters:
|
||||
- kubernetes_deployment
|
||||
- kubernetes_service
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert len(profiles) == 1
|
||||
profile = profiles[0]
|
||||
assert profile.provider == ProviderType.KUBERNETES
|
||||
assert profile.credentials == {
|
||||
"kubeconfig_path": "/home/user/.kube/config",
|
||||
"context": "pi-cluster",
|
||||
}
|
||||
assert profile.endpoints == ["https://k8s-api.internal.lab:6443"]
|
||||
assert profile.resource_type_filters == [
|
||||
"kubernetes_deployment",
|
||||
"kubernetes_service",
|
||||
]
|
||||
|
||||
def test_loads_profile_without_optional_fields(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
provider: docker_swarm
|
||||
credentials:
|
||||
host: tcp://swarm-manager:2376
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert len(profiles) == 1
|
||||
profile = profiles[0]
|
||||
assert profile.provider == ProviderType.DOCKER_SWARM
|
||||
assert profile.endpoints is None
|
||||
assert profile.resource_type_filters is None
|
||||
assert profile.authentik_token is None
|
||||
|
||||
|
||||
class TestMultiProfileLoading:
|
||||
"""Tests for loading multiple profiles from a YAML list."""
|
||||
|
||||
def test_loads_multi_profile_yaml(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
- provider: kubernetes
|
||||
credentials:
|
||||
kubeconfig_path: /home/user/.kube/config
|
||||
context: pi-cluster
|
||||
endpoints:
|
||||
- https://k8s-api.internal.lab:6443
|
||||
|
||||
- provider: synology
|
||||
credentials:
|
||||
host: nas01.internal.lab
|
||||
port: "5001"
|
||||
username: admin
|
||||
password: secret
|
||||
endpoints:
|
||||
- nas01.internal.lab:5001
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert len(profiles) == 2
|
||||
assert profiles[0].provider == ProviderType.KUBERNETES
|
||||
assert profiles[1].provider == ProviderType.SYNOLOGY
|
||||
assert profiles[1].credentials["host"] == "nas01.internal.lab"
|
||||
|
||||
def test_loads_three_profiles(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
- provider: kubernetes
|
||||
credentials:
|
||||
context: cluster-1
|
||||
|
||||
- provider: docker_swarm
|
||||
credentials:
|
||||
host: tcp://swarm:2376
|
||||
|
||||
- provider: windows
|
||||
credentials:
|
||||
host: win-server-01
|
||||
username: admin
|
||||
password: pass
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert len(profiles) == 3
|
||||
assert profiles[0].provider == ProviderType.KUBERNETES
|
||||
assert profiles[1].provider == ProviderType.DOCKER_SWARM
|
||||
assert profiles[2].provider == ProviderType.WINDOWS
|
||||
|
||||
|
||||
class TestEnvVarExpansion:
|
||||
"""Tests for ${ENV_VAR} and ${ENV_VAR:-default} expansion."""
|
||||
|
||||
def test_expands_env_var(self, loader, monkeypatch):
|
||||
monkeypatch.setenv("MY_SECRET", "super-secret-value")
|
||||
|
||||
result = loader.expand_env_vars("${MY_SECRET}")
|
||||
|
||||
assert result == "super-secret-value"
|
||||
|
||||
def test_expands_env_var_with_surrounding_text(self, loader, monkeypatch):
|
||||
monkeypatch.setenv("HOST", "myserver.local")
|
||||
|
||||
result = loader.expand_env_vars("https://${HOST}:8443")
|
||||
|
||||
assert result == "https://myserver.local:8443"
|
||||
|
||||
def test_expands_multiple_env_vars(self, loader, monkeypatch):
|
||||
monkeypatch.setenv("USER", "admin")
|
||||
monkeypatch.setenv("PASS", "secret123")
|
||||
|
||||
result = loader.expand_env_vars("${USER}:${PASS}")
|
||||
|
||||
assert result == "admin:secret123"
|
||||
|
||||
def test_expands_env_var_with_default(self, loader, monkeypatch):
|
||||
monkeypatch.delenv("MISSING_VAR", raising=False)
|
||||
|
||||
result = loader.expand_env_vars("${MISSING_VAR:-fallback_value}")
|
||||
|
||||
assert result == "fallback_value"
|
||||
|
||||
def test_env_var_set_overrides_default(self, loader, monkeypatch):
|
||||
monkeypatch.setenv("MY_VAR", "actual_value")
|
||||
|
||||
result = loader.expand_env_vars("${MY_VAR:-default_value}")
|
||||
|
||||
assert result == "actual_value"
|
||||
|
||||
def test_empty_default_is_valid(self, loader, monkeypatch):
|
||||
monkeypatch.delenv("UNSET_VAR", raising=False)
|
||||
|
||||
result = loader.expand_env_vars("prefix_${UNSET_VAR:-}_suffix")
|
||||
|
||||
assert result == "prefix__suffix"
|
||||
|
||||
def test_missing_env_var_without_default_raises_error(self, loader, monkeypatch):
|
||||
monkeypatch.delenv("NONEXISTENT_VAR", raising=False)
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="NONEXISTENT_VAR"):
|
||||
loader.expand_env_vars("${NONEXISTENT_VAR}")
|
||||
|
||||
def test_no_env_vars_returns_unchanged(self, loader):
|
||||
result = loader.expand_env_vars("plain text without vars")
|
||||
|
||||
assert result == "plain text without vars"
|
||||
|
||||
|
||||
class TestCredentialExpansion:
|
||||
"""Tests for env var expansion applied to credential fields in profiles."""
|
||||
|
||||
def test_expands_credentials_in_profile(self, loader, tmp_profile, monkeypatch):
|
||||
monkeypatch.setenv("SYNOLOGY_USER", "admin")
|
||||
monkeypatch.setenv("SYNOLOGY_PASSWORD", "my_password")
|
||||
|
||||
path = tmp_profile("""
|
||||
provider: synology
|
||||
credentials:
|
||||
host: nas01.internal.lab
|
||||
username: "${SYNOLOGY_USER}"
|
||||
password: "${SYNOLOGY_PASSWORD}"
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert profiles[0].credentials["username"] == "admin"
|
||||
assert profiles[0].credentials["password"] == "my_password"
|
||||
# Non-env-var values remain unchanged
|
||||
assert profiles[0].credentials["host"] == "nas01.internal.lab"
|
||||
|
||||
def test_expands_nested_credential_values(self, loader, tmp_profile, monkeypatch):
|
||||
monkeypatch.setenv("INNER_SECRET", "nested_value")
|
||||
|
||||
path = tmp_profile("""
|
||||
provider: windows
|
||||
credentials:
|
||||
host: win-server-01
|
||||
auth:
|
||||
token: "${INNER_SECRET}"
|
||||
type: bearer
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert profiles[0].credentials["auth"]["token"] == "nested_value"
|
||||
assert profiles[0].credentials["auth"]["type"] == "bearer"
|
||||
|
||||
def test_expands_authentik_token(self, loader, tmp_profile, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_TOKEN", "my-sso-token")
|
||||
|
||||
path = tmp_profile("""
|
||||
provider: kubernetes
|
||||
credentials:
|
||||
context: cluster-1
|
||||
authentik_token: "${AUTH_TOKEN}"
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert profiles[0].authentik_token == "my-sso-token"
|
||||
|
||||
def test_credential_with_default_value(self, loader, tmp_profile, monkeypatch):
|
||||
monkeypatch.delenv("OPTIONAL_PORT", raising=False)
|
||||
|
||||
path = tmp_profile("""
|
||||
provider: synology
|
||||
credentials:
|
||||
host: nas01
|
||||
port: "${OPTIONAL_PORT:-5001}"
|
||||
""")
|
||||
|
||||
profiles = loader.load(path)
|
||||
|
||||
assert profiles[0].credentials["port"] == "5001"
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Tests for error cases in profile loading."""
|
||||
|
||||
def test_file_not_found_raises_error(self, loader):
|
||||
with pytest.raises(ProfileLoaderError, match="Profile not found"):
|
||||
loader.load("/nonexistent/path/profile.yaml")
|
||||
|
||||
def test_invalid_yaml_raises_error(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
provider: kubernetes
|
||||
credentials: [invalid: yaml: content
|
||||
""")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="Invalid YAML"):
|
||||
loader.load(path)
|
||||
|
||||
def test_empty_file_raises_error(self, loader, tmp_path):
|
||||
profile_file = tmp_path / "empty.yaml"
|
||||
profile_file.write_text("", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="empty"):
|
||||
loader.load(str(profile_file))
|
||||
|
||||
def test_unknown_provider_raises_error(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
provider: unknown_provider
|
||||
credentials:
|
||||
key: value
|
||||
""")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="Unknown provider"):
|
||||
loader.load(path)
|
||||
|
||||
def test_missing_provider_raises_error(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
credentials:
|
||||
key: value
|
||||
""")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="Missing 'provider'"):
|
||||
loader.load(path)
|
||||
|
||||
def test_missing_env_var_in_credentials_raises_error(
|
||||
self, loader, tmp_profile, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("MISSING_CRED", raising=False)
|
||||
|
||||
path = tmp_profile("""
|
||||
provider: kubernetes
|
||||
credentials:
|
||||
token: "${MISSING_CRED}"
|
||||
""")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="MISSING_CRED"):
|
||||
loader.load(path)
|
||||
|
||||
def test_non_dict_in_multi_profile_raises_error(self, loader, tmp_profile):
|
||||
path = tmp_profile("""
|
||||
- provider: kubernetes
|
||||
credentials:
|
||||
context: cluster-1
|
||||
- just a string
|
||||
""")
|
||||
|
||||
with pytest.raises(ProfileLoaderError, match="index 1.*mapping"):
|
||||
loader.load(path)
|
||||
414
tests/unit/test_provider_block.py
Normal file
414
tests/unit/test_provider_block.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""Unit tests for the ProviderBlockGenerator."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import ProviderType, ScanProfile
|
||||
from iac_reverse.generator import ProviderBlockGenerator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_profile(
|
||||
provider: ProviderType,
|
||||
credentials: dict[str, str] | None = None,
|
||||
) -> ScanProfile:
|
||||
"""Create a ScanProfile with sensible defaults for testing."""
|
||||
default_creds: dict[ProviderType, dict[str, str]] = {
|
||||
ProviderType.KUBERNETES: {
|
||||
"host": "https://k8s-api.local:6443",
|
||||
"cluster_ca_certificate": "/path/to/ca.crt",
|
||||
"token": "my-token-123",
|
||||
},
|
||||
ProviderType.DOCKER_SWARM: {
|
||||
"host": "tcp://swarm-manager:2376",
|
||||
"cert_path": "/home/user/.docker/certs",
|
||||
},
|
||||
ProviderType.SYNOLOGY: {
|
||||
"url": "https://nas.local:5001",
|
||||
"username": "admin",
|
||||
"password": "secret123",
|
||||
},
|
||||
ProviderType.HARVESTER: {
|
||||
"kubeconfig": "/home/user/.kube/harvester.yaml",
|
||||
},
|
||||
ProviderType.BARE_METAL: {
|
||||
"endpoint": "https://bmc.local/redfish/v1",
|
||||
"username": "root",
|
||||
"password": "calvin",
|
||||
},
|
||||
ProviderType.WINDOWS: {
|
||||
"host": "win-server-01.local",
|
||||
"username": "Administrator",
|
||||
"password": "P@ssw0rd",
|
||||
"winrm_port": "5986",
|
||||
"winrm_use_ssl": "true",
|
||||
},
|
||||
}
|
||||
creds = credentials if credentials is not None else default_creds[provider]
|
||||
return ScanProfile(provider=provider, credentials=creds)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Single provider generates one provider block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleProvider:
|
||||
"""Tests for generating a single provider block."""
|
||||
|
||||
def test_kubernetes_generates_one_provider_block(self):
|
||||
"""A single Kubernetes provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES},
|
||||
)
|
||||
|
||||
assert result.filename == "providers.tf"
|
||||
assert result.content.count('provider "kubernetes"') == 1
|
||||
|
||||
def test_docker_generates_one_provider_block(self):
|
||||
"""A single Docker Swarm provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.DOCKER_SWARM)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.DOCKER_SWARM},
|
||||
)
|
||||
|
||||
assert result.content.count('provider "docker"') == 1
|
||||
|
||||
def test_synology_generates_one_provider_block(self):
|
||||
"""A single Synology provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.SYNOLOGY)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.SYNOLOGY},
|
||||
)
|
||||
|
||||
assert result.content.count('provider "synology"') == 1
|
||||
|
||||
def test_harvester_generates_one_provider_block(self):
|
||||
"""A single Harvester provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.HARVESTER)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.HARVESTER},
|
||||
)
|
||||
|
||||
assert result.content.count('provider "harvester"') == 1
|
||||
|
||||
def test_bare_metal_generates_one_provider_block(self):
|
||||
"""A single Bare Metal provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.BARE_METAL)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.BARE_METAL},
|
||||
)
|
||||
|
||||
assert result.content.count('provider "redfish"') == 1
|
||||
|
||||
def test_windows_generates_one_provider_block(self):
|
||||
"""A single Windows provider generates one provider block."""
|
||||
profile = make_profile(ProviderType.WINDOWS)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.WINDOWS},
|
||||
)
|
||||
|
||||
assert result.content.count('provider "windows"') == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multiple providers generate multiple blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleProviders:
|
||||
"""Tests for generating multiple provider blocks."""
|
||||
|
||||
def test_two_providers_generate_two_blocks(self):
|
||||
"""Two distinct providers generate two provider blocks."""
|
||||
profiles = [
|
||||
make_profile(ProviderType.KUBERNETES),
|
||||
make_profile(ProviderType.DOCKER_SWARM),
|
||||
]
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=profiles,
|
||||
provider_types={ProviderType.KUBERNETES, ProviderType.DOCKER_SWARM},
|
||||
)
|
||||
|
||||
assert 'provider "kubernetes"' in result.content
|
||||
assert 'provider "docker"' in result.content
|
||||
|
||||
def test_three_providers_generate_three_blocks(self):
|
||||
"""Three distinct providers generate three provider blocks."""
|
||||
profiles = [
|
||||
make_profile(ProviderType.KUBERNETES),
|
||||
make_profile(ProviderType.SYNOLOGY),
|
||||
make_profile(ProviderType.WINDOWS),
|
||||
]
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=profiles,
|
||||
provider_types={
|
||||
ProviderType.KUBERNETES,
|
||||
ProviderType.SYNOLOGY,
|
||||
ProviderType.WINDOWS,
|
||||
},
|
||||
)
|
||||
|
||||
assert 'provider "kubernetes"' in result.content
|
||||
assert 'provider "synology"' in result.content
|
||||
assert 'provider "windows"' in result.content
|
||||
|
||||
def test_all_six_providers(self):
|
||||
"""All six provider types generate six provider blocks."""
|
||||
all_types = set(ProviderType)
|
||||
profiles = [make_profile(pt) for pt in all_types]
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=profiles,
|
||||
provider_types=all_types,
|
||||
)
|
||||
|
||||
assert 'provider "kubernetes"' in result.content
|
||||
assert 'provider "docker"' in result.content
|
||||
assert 'provider "synology"' in result.content
|
||||
assert 'provider "harvester"' in result.content
|
||||
assert 'provider "redfish"' in result.content
|
||||
assert 'provider "windows"' in result.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Provider-specific configuration is included
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderSpecificConfig:
|
||||
"""Tests for provider-specific configuration attributes."""
|
||||
|
||||
def test_kubernetes_includes_host_ca_token(self):
|
||||
"""Kubernetes provider block includes host, cluster_ca_certificate, token."""
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES},
|
||||
)
|
||||
|
||||
assert "https://k8s-api.local:6443" in result.content
|
||||
assert "/path/to/ca.crt" in result.content
|
||||
assert "my-token-123" in result.content
|
||||
assert "host" in result.content
|
||||
assert "cluster_ca_certificate" in result.content
|
||||
assert "token" in result.content
|
||||
|
||||
def test_docker_includes_host_cert_path(self):
|
||||
"""Docker provider block includes host and cert_path."""
|
||||
profile = make_profile(ProviderType.DOCKER_SWARM)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.DOCKER_SWARM},
|
||||
)
|
||||
|
||||
assert "tcp://swarm-manager:2376" in result.content
|
||||
assert "/home/user/.docker/certs" in result.content
|
||||
assert "host" in result.content
|
||||
assert "cert_path" in result.content
|
||||
|
||||
def test_synology_includes_url_username_password(self):
|
||||
"""Synology provider block includes url, username, password."""
|
||||
profile = make_profile(ProviderType.SYNOLOGY)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.SYNOLOGY},
|
||||
)
|
||||
|
||||
assert "https://nas.local:5001" in result.content
|
||||
assert "admin" in result.content
|
||||
assert "secret123" in result.content
|
||||
assert "url" in result.content
|
||||
assert "username" in result.content
|
||||
assert "password" in result.content
|
||||
|
||||
def test_harvester_includes_kubeconfig(self):
|
||||
"""Harvester provider block includes kubeconfig."""
|
||||
profile = make_profile(ProviderType.HARVESTER)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.HARVESTER},
|
||||
)
|
||||
|
||||
assert "/home/user/.kube/harvester.yaml" in result.content
|
||||
assert "kubeconfig" in result.content
|
||||
|
||||
def test_bare_metal_includes_endpoint_username_password(self):
|
||||
"""Bare Metal (redfish) provider block includes endpoint, username, password."""
|
||||
profile = make_profile(ProviderType.BARE_METAL)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.BARE_METAL},
|
||||
)
|
||||
|
||||
assert "https://bmc.local/redfish/v1" in result.content
|
||||
assert "root" in result.content
|
||||
assert "calvin" in result.content
|
||||
assert "endpoint" in result.content
|
||||
assert "username" in result.content
|
||||
assert "password" in result.content
|
||||
|
||||
def test_windows_includes_host_username_password_winrm(self):
|
||||
"""Windows provider block includes host, username, password, winrm settings."""
|
||||
profile = make_profile(ProviderType.WINDOWS)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.WINDOWS},
|
||||
)
|
||||
|
||||
assert "win-server-01.local" in result.content
|
||||
assert "Administrator" in result.content
|
||||
assert "P@ssw0rd" in result.content
|
||||
assert "winrm" in result.content
|
||||
assert "5986" in result.content
|
||||
assert "use_ssl" in result.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: required_providers block is generated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRequiredProvidersBlock:
|
||||
"""Tests for the terraform { required_providers { ... } } block."""
|
||||
|
||||
def test_required_providers_block_present(self):
|
||||
"""The output contains a terraform { required_providers { ... } } block."""
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES},
|
||||
)
|
||||
|
||||
assert "terraform {" in result.content
|
||||
assert "required_providers {" in result.content
|
||||
|
||||
def test_required_providers_includes_source(self):
|
||||
"""The required_providers block includes source for each provider."""
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES},
|
||||
)
|
||||
|
||||
assert 'source = "hashicorp/kubernetes"' in result.content
|
||||
|
||||
def test_required_providers_includes_version(self):
|
||||
"""The required_providers block includes version constraint."""
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES},
|
||||
)
|
||||
|
||||
assert 'version = "~> 2.0"' in result.content
|
||||
|
||||
def test_multiple_providers_all_in_required_providers(self):
|
||||
"""Multiple providers are all listed in required_providers."""
|
||||
profiles = [
|
||||
make_profile(ProviderType.KUBERNETES),
|
||||
make_profile(ProviderType.DOCKER_SWARM),
|
||||
make_profile(ProviderType.BARE_METAL),
|
||||
]
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=profiles,
|
||||
provider_types={
|
||||
ProviderType.KUBERNETES,
|
||||
ProviderType.DOCKER_SWARM,
|
||||
ProviderType.BARE_METAL,
|
||||
},
|
||||
)
|
||||
|
||||
assert 'source = "hashicorp/kubernetes"' in result.content
|
||||
assert 'source = "kreuzwerker/docker"' in result.content
|
||||
assert 'source = "dell/redfish"' in result.content
|
||||
|
||||
def test_docker_swarm_maps_to_kreuzwerker_docker(self):
|
||||
"""Docker Swarm maps to kreuzwerker/docker provider source."""
|
||||
profile = make_profile(ProviderType.DOCKER_SWARM)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.DOCKER_SWARM},
|
||||
)
|
||||
|
||||
assert "docker = {" in result.content
|
||||
assert 'source = "kreuzwerker/docker"' in result.content
|
||||
|
||||
def test_harvester_maps_to_harvester_source(self):
|
||||
"""Harvester maps to harvester/harvester provider source."""
|
||||
profile = make_profile(ProviderType.HARVESTER)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.HARVESTER},
|
||||
)
|
||||
|
||||
assert "harvester = {" in result.content
|
||||
assert 'source = "harvester/harvester"' in result.content
|
||||
|
||||
def test_provider_type_without_matching_profile_gets_placeholder(self):
|
||||
"""A provider type with no matching profile gets a placeholder block."""
|
||||
# Profile is for Kubernetes, but we request Synology too
|
||||
profile = make_profile(ProviderType.KUBERNETES)
|
||||
generator = ProviderBlockGenerator()
|
||||
|
||||
result = generator.generate(
|
||||
profiles=[profile],
|
||||
provider_types={ProviderType.KUBERNETES, ProviderType.SYNOLOGY},
|
||||
)
|
||||
|
||||
# Synology should still appear in required_providers
|
||||
assert "synology = {" in result.content
|
||||
assert 'source = "synology-community/synology"' in result.content
|
||||
# And should have a placeholder provider block
|
||||
assert '# No profile provided' in result.content
|
||||
481
tests/unit/test_resolver.py
Normal file
481
tests/unit/test_resolver.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""Unit tests for the DependencyResolver."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DependencyGraph,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.resolver import DependencyResolver
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "default/deployments/nginx",
|
||||
name: str = "nginx",
|
||||
raw_references: list[str] | None = None,
|
||||
attributes: dict | None = None,
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
) -> 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=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes=attributes or {},
|
||||
raw_references=raw_references or [],
|
||||
)
|
||||
|
||||
|
||||
def make_scan_result(resources: list[DiscoveredResource]) -> ScanResult:
|
||||
"""Create a ScanResult from a list of resources."""
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="test-hash",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Simple linear dependency chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLinearDependencyChain:
|
||||
"""Tests for a simple A -> B -> C dependency chain."""
|
||||
|
||||
def test_linear_chain_produces_correct_topological_order(self):
|
||||
"""Resources in a linear chain appear in dependency order."""
|
||||
# C depends on B, B depends on A
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="default/services/nginx-svc",
|
||||
name="nginx-svc",
|
||||
raw_references=["ns/default"],
|
||||
attributes={"namespace": "default"},
|
||||
)
|
||||
resource_c = make_resource(
|
||||
resource_type="kubernetes_ingress",
|
||||
unique_id="default/ingresses/nginx-ingress",
|
||||
name="nginx-ingress",
|
||||
raw_references=["default/services/nginx-svc"],
|
||||
attributes={"service": "nginx-svc"},
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b, resource_c])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# A must appear before B, B must appear before C
|
||||
order = graph.topological_order
|
||||
assert order.index("ns/default") < order.index("default/services/nginx-svc")
|
||||
assert order.index("default/services/nginx-svc") < order.index(
|
||||
"default/ingresses/nginx-ingress"
|
||||
)
|
||||
|
||||
def test_linear_chain_produces_correct_relationships(self):
|
||||
"""Each link in the chain produces a relationship."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="default/services/nginx-svc",
|
||||
name="nginx-svc",
|
||||
raw_references=["ns/default"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 1
|
||||
rel = graph.relationships[0]
|
||||
assert rel.source_id == "default/services/nginx-svc"
|
||||
assert rel.target_id == "ns/default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multiple resources with shared dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharedDependencies:
|
||||
"""Tests for multiple resources depending on the same resource."""
|
||||
|
||||
def test_shared_dependency_appears_before_all_dependents(self):
|
||||
"""A shared dependency appears before all resources that depend on it."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/production",
|
||||
name="production",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="production/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/production"],
|
||||
)
|
||||
service = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="production/services/app-svc",
|
||||
name="app-svc",
|
||||
raw_references=["ns/production"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment, service])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
order = graph.topological_order
|
||||
ns_idx = order.index("ns/production")
|
||||
deploy_idx = order.index("production/deployments/app")
|
||||
svc_idx = order.index("production/services/app-svc")
|
||||
|
||||
assert ns_idx < deploy_idx
|
||||
assert ns_idx < svc_idx
|
||||
|
||||
def test_shared_dependency_produces_multiple_relationships(self):
|
||||
"""A shared dependency creates one relationship per dependent."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/production",
|
||||
name="production",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="production/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/production"],
|
||||
)
|
||||
service = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="production/services/app-svc",
|
||||
name="app-svc",
|
||||
raw_references=["ns/production"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment, service])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 2
|
||||
target_ids = [r.target_id for r in graph.relationships]
|
||||
assert target_ids.count("ns/production") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Resources with no references (standalone)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStandaloneResources:
|
||||
"""Tests for resources that have no references to other resources."""
|
||||
|
||||
def test_standalone_resources_appear_in_topological_order(self):
|
||||
"""Resources with no references still appear in the topological order."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/standalone-a",
|
||||
name="standalone-a",
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="default/services/standalone-b",
|
||||
name="standalone-b",
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.topological_order) == 2
|
||||
assert "default/deployments/standalone-a" in graph.topological_order
|
||||
assert "default/services/standalone-b" in graph.topological_order
|
||||
|
||||
def test_standalone_resources_produce_no_relationships(self):
|
||||
"""Resources with no references produce no relationships."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/standalone",
|
||||
name="standalone",
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 0
|
||||
|
||||
def test_empty_scan_result_produces_empty_graph(self):
|
||||
"""An empty scan result produces an empty dependency graph."""
|
||||
scan_result = make_scan_result([])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.resources == []
|
||||
assert graph.relationships == []
|
||||
assert graph.topological_order == []
|
||||
assert graph.cycles == []
|
||||
assert graph.unresolved_references == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Parent-child relationship detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParentChildRelationships:
|
||||
"""Tests for parent-child relationship classification."""
|
||||
|
||||
def test_namespace_reference_classified_as_parent_child(self):
|
||||
"""A reference to a kubernetes_namespace is classified as parent-child."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/default"],
|
||||
attributes={"namespace": "default"},
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].relationship_type == "parent-child"
|
||||
|
||||
def test_docker_network_reference_classified_as_parent_child(self):
|
||||
"""A reference to a docker_network is classified as parent-child."""
|
||||
network = make_resource(
|
||||
resource_type="docker_network",
|
||||
unique_id="networks/overlay-net",
|
||||
name="overlay-net",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
)
|
||||
service = make_resource(
|
||||
resource_type="docker_service",
|
||||
unique_id="services/web",
|
||||
name="web",
|
||||
raw_references=["networks/overlay-net"],
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([network, service])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].relationship_type == "parent-child"
|
||||
|
||||
def test_dependency_relationship_for_iis_site_to_app_pool(self):
|
||||
"""An IIS site referencing an app pool is classified as dependency."""
|
||||
app_pool = make_resource(
|
||||
resource_type="windows_iis_app_pool",
|
||||
unique_id="win-server/iis/app_pools/DefaultAppPool",
|
||||
name="DefaultAppPool",
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
iis_site = make_resource(
|
||||
resource_type="windows_iis_site",
|
||||
unique_id="win-server/iis/sites/Default Web Site",
|
||||
name="Default Web Site",
|
||||
raw_references=["win-server/iis/app_pools/DefaultAppPool"],
|
||||
attributes={"app_pool": "DefaultAppPool"},
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([app_pool, iis_site])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].relationship_type == "dependency"
|
||||
|
||||
def test_generic_reference_classified_as_reference(self):
|
||||
"""A reference to a non-namespace, non-dependency resource is 'reference'."""
|
||||
service = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="default/services/nginx-svc",
|
||||
name="nginx-svc",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/nginx",
|
||||
name="nginx",
|
||||
raw_references=["default/services/nginx-svc"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([service, deployment])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].relationship_type == "reference"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Topological order validity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTopologicalOrderValidity:
|
||||
"""Tests that topological order is valid (no resource before its dependencies)."""
|
||||
|
||||
def test_no_resource_appears_before_its_dependencies(self):
|
||||
"""In the topological order, no resource appears before any it depends on."""
|
||||
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",
|
||||
raw_references=["ns/prod"],
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="prod/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/prod", "prod/configmaps/app-config"],
|
||||
)
|
||||
service = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="prod/services/app-svc",
|
||||
name="app-svc",
|
||||
raw_references=["ns/prod"],
|
||||
)
|
||||
ingress = make_resource(
|
||||
resource_type="kubernetes_ingress",
|
||||
unique_id="prod/ingresses/app-ingress",
|
||||
name="app-ingress",
|
||||
raw_references=["prod/services/app-svc"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result(
|
||||
[namespace, config_map, deployment, service, ingress]
|
||||
)
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
order = graph.topological_order
|
||||
|
||||
# Verify: for each relationship, target appears before source
|
||||
for rel in graph.relationships:
|
||||
target_idx = order.index(rel.target_id)
|
||||
source_idx = order.index(rel.source_id)
|
||||
assert target_idx < source_idx, (
|
||||
f"Target {rel.target_id} (idx={target_idx}) should appear before "
|
||||
f"source {rel.source_id} (idx={source_idx})"
|
||||
)
|
||||
|
||||
def test_topological_order_contains_all_resources(self):
|
||||
"""The topological order contains every resource exactly once."""
|
||||
resources = [
|
||||
make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id=f"default/deployments/app-{i}",
|
||||
name=f"app-{i}",
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
scan_result = make_scan_result(resources)
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.topological_order) == 5
|
||||
assert len(set(graph.topological_order)) == 5 # All unique
|
||||
|
||||
def test_graph_returns_all_resources_in_resources_field(self):
|
||||
"""The DependencyGraph.resources field contains all input resources."""
|
||||
resources = [
|
||||
make_resource(
|
||||
unique_id=f"resource-{i}",
|
||||
name=f"res-{i}",
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
scan_result = make_scan_result(resources)
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.resources == resources
|
||||
|
||||
def test_source_attribute_identified_from_attributes(self):
|
||||
"""The source_attribute is identified from the resource's attributes dict."""
|
||||
app_pool = make_resource(
|
||||
resource_type="windows_iis_app_pool",
|
||||
unique_id="win/iis/app_pools/MyPool",
|
||||
name="MyPool",
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
site = make_resource(
|
||||
resource_type="windows_iis_site",
|
||||
unique_id="win/iis/sites/MySite",
|
||||
name="MySite",
|
||||
raw_references=["win/iis/app_pools/MyPool"],
|
||||
attributes={"app_pool": "MyPool", "state": "Started"},
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([app_pool, site])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.relationships[0].source_attribute == "app_pool"
|
||||
|
||||
def test_unresolved_references_are_skipped(self):
|
||||
"""References to IDs not in the inventory are skipped (no relationship created)."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["nonexistent/resource/id"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 0
|
||||
# Unresolved references are now tracked (task 4.3)
|
||||
assert len(graph.unresolved_references) == 1
|
||||
assert graph.unresolved_references[0].referenced_id == "nonexistent/resource/id"
|
||||
537
tests/unit/test_resolver_cycles.py
Normal file
537
tests/unit/test_resolver_cycles.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""Unit tests for cycle detection and resolution in the DependencyResolver."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
CycleReport,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.resolver import DependencyResolver
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "default/deployments/nginx",
|
||||
name: str = "nginx",
|
||||
raw_references: list[str] | None = None,
|
||||
attributes: dict | None = None,
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
) -> 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=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes=attributes or {},
|
||||
raw_references=raw_references or [],
|
||||
)
|
||||
|
||||
|
||||
def make_scan_result(resources: list[DiscoveredResource]) -> ScanResult:
|
||||
"""Create a ScanResult from a list of resources."""
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="test-hash",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Simple A -> B -> A cycle detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSimpleTwoNodeCycle:
|
||||
"""Tests for a simple two-node cycle: A -> B -> A."""
|
||||
|
||||
def test_two_node_cycle_detected(self):
|
||||
"""A simple A -> B -> A cycle is detected."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Should detect exactly one cycle
|
||||
assert len(graph.cycles) == 1
|
||||
# The cycle should contain both resource IDs
|
||||
cycle = graph.cycles[0]
|
||||
assert set(cycle) == {"svc-a", "svc-b"}
|
||||
|
||||
def test_two_node_cycle_has_cycle_report(self):
|
||||
"""A two-node cycle produces a CycleReport with resolution suggestion."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.cycle_reports) == 1
|
||||
report = graph.cycle_reports[0]
|
||||
assert isinstance(report, CycleReport)
|
||||
assert "data source" in report.resolution_strategy.lower()
|
||||
|
||||
def test_two_node_cycle_still_produces_topological_order(self):
|
||||
"""Despite a cycle, a topological order is still produced."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Topological order should contain both resources
|
||||
assert len(graph.topological_order) == 2
|
||||
assert set(graph.topological_order) == {"svc-a", "svc-b"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multi-node cycle (A -> B -> C -> A)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiNodeCycle:
|
||||
"""Tests for a multi-node cycle: A -> B -> C -> A."""
|
||||
|
||||
def test_three_node_cycle_detected(self):
|
||||
"""A three-node cycle A -> B -> C -> A is detected."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-c"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
resource_c = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-c",
|
||||
name="service-c",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b, resource_c])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Should detect exactly one cycle containing all three nodes
|
||||
assert len(graph.cycles) == 1
|
||||
cycle = graph.cycles[0]
|
||||
assert set(cycle) == {"svc-a", "svc-b", "svc-c"}
|
||||
|
||||
def test_three_node_cycle_produces_topological_order(self):
|
||||
"""A three-node cycle still produces a valid topological order."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-c"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
resource_c = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-c",
|
||||
name="service-c",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b, resource_c])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.topological_order) == 3
|
||||
assert set(graph.topological_order) == {"svc-a", "svc-b", "svc-c"}
|
||||
|
||||
def test_three_node_cycle_report_has_all_nodes(self):
|
||||
"""The cycle report for a 3-node cycle lists all involved resources."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-c"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
resource_c = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-c",
|
||||
name="service-c",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b, resource_c])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.cycle_reports) == 1
|
||||
report = graph.cycle_reports[0]
|
||||
assert set(report.cycle) == {"svc-a", "svc-b", "svc-c"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Graph with both cycles and acyclic portions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMixedCyclicAndAcyclicGraph:
|
||||
"""Tests for graphs containing both cyclic and acyclic portions."""
|
||||
|
||||
def test_cycle_detected_alongside_acyclic_resources(self):
|
||||
"""Cycles are detected even when acyclic resources exist in the graph."""
|
||||
# Acyclic chain: D -> E (E depends on D)
|
||||
resource_d = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/production",
|
||||
name="production",
|
||||
)
|
||||
resource_e = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="prod/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/production"],
|
||||
)
|
||||
|
||||
# Cycle: A -> B -> A
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-x",
|
||||
name="service-x",
|
||||
raw_references=["svc-y"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-y",
|
||||
name="service-y",
|
||||
raw_references=["svc-x"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result(
|
||||
[resource_d, resource_e, resource_a, resource_b]
|
||||
)
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Should detect the cycle
|
||||
assert len(graph.cycles) == 1
|
||||
assert set(graph.cycles[0]) == {"svc-x", "svc-y"}
|
||||
|
||||
def test_acyclic_ordering_preserved_despite_cycle_elsewhere(self):
|
||||
"""Acyclic portions maintain correct topological ordering."""
|
||||
# Acyclic chain: namespace -> deployment
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/prod",
|
||||
name="prod",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="prod/deploy/app",
|
||||
name="app",
|
||||
raw_references=["ns/prod"],
|
||||
)
|
||||
|
||||
# Cycle: X -> Y -> X
|
||||
svc_x = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-x",
|
||||
name="svc-x",
|
||||
raw_references=["svc-y"],
|
||||
)
|
||||
svc_y = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-y",
|
||||
name="svc-y",
|
||||
raw_references=["svc-x"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment, svc_x, svc_y])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
order = graph.topological_order
|
||||
# The acyclic relationship should still be respected
|
||||
assert order.index("ns/prod") < order.index("prod/deploy/app")
|
||||
|
||||
def test_all_resources_present_in_topological_order(self):
|
||||
"""All resources (cyclic and acyclic) appear in the topological order."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deploy/web",
|
||||
name="web",
|
||||
raw_references=["ns/default"],
|
||||
)
|
||||
svc_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="cycle-a",
|
||||
name="cycle-a",
|
||||
raw_references=["cycle-b"],
|
||||
)
|
||||
svc_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="cycle-b",
|
||||
name="cycle-b",
|
||||
raw_references=["cycle-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment, svc_a, svc_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.topological_order) == 4
|
||||
assert set(graph.topological_order) == {
|
||||
"ns/default",
|
||||
"default/deploy/web",
|
||||
"cycle-a",
|
||||
"cycle-b",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Resolution suggestion identifies correct relationship to break
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolutionSuggestions:
|
||||
"""Tests that resolution suggestions prefer breaking 'reference' over
|
||||
'dependency' over 'parent-child'."""
|
||||
|
||||
def test_prefers_breaking_reference_over_dependency(self):
|
||||
"""When a cycle has both reference and dependency edges, suggests breaking reference."""
|
||||
# Create a cycle where one edge is "dependency" and one is "reference"
|
||||
# IIS site -> app pool (dependency), app pool -> IIS site (reference)
|
||||
app_pool = make_resource(
|
||||
resource_type="windows_iis_app_pool",
|
||||
unique_id="pool-a",
|
||||
name="pool-a",
|
||||
raw_references=["site-a"], # This creates a "reference" relationship
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
iis_site = make_resource(
|
||||
resource_type="windows_iis_site",
|
||||
unique_id="site-a",
|
||||
name="site-a",
|
||||
raw_references=["pool-a"], # This creates a "dependency" relationship
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([app_pool, iis_site])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.cycle_reports) == 1
|
||||
report = graph.cycle_reports[0]
|
||||
# Should suggest breaking the "reference" relationship (pool -> site)
|
||||
assert report.break_relationship_type == "reference"
|
||||
|
||||
def test_prefers_breaking_dependency_over_parent_child(self):
|
||||
"""When a cycle has dependency and parent-child edges, suggests breaking dependency."""
|
||||
# Create a cycle: namespace -> deployment (parent-child back-ref),
|
||||
# deployment -> namespace (dependency)
|
||||
# We need to craft this carefully:
|
||||
# - deployment references namespace -> classified as "parent-child" (namespace is in _NAMESPACE_RESOURCE_TYPES)
|
||||
# - namespace references deployment -> classified as "reference" (deployment is not special)
|
||||
# Actually let's use a different setup to get dependency vs parent-child
|
||||
|
||||
# Use harvester: VM -> network (dependency), network -> VM (reference)
|
||||
network = make_resource(
|
||||
resource_type="harvester_network",
|
||||
unique_id="net-a",
|
||||
name="net-a",
|
||||
raw_references=["vm-a"], # reference (VM is not a namespace type)
|
||||
provider=ProviderType.HARVESTER,
|
||||
platform_category=PlatformCategory.HCI,
|
||||
)
|
||||
vm = make_resource(
|
||||
resource_type="harvester_virtualmachine",
|
||||
unique_id="vm-a",
|
||||
name="vm-a",
|
||||
raw_references=["net-a"], # dependency (VM depends on network)
|
||||
provider=ProviderType.HARVESTER,
|
||||
platform_category=PlatformCategory.HCI,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([network, vm])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.cycle_reports) == 1
|
||||
report = graph.cycle_reports[0]
|
||||
# The network -> VM edge is "reference", VM -> network is "parent-child"
|
||||
# (because harvester_network is in _NAMESPACE_RESOURCE_TYPES)
|
||||
# So it should prefer breaking "reference"
|
||||
assert report.break_relationship_type == "reference"
|
||||
|
||||
def test_resolution_strategy_mentions_data_source(self):
|
||||
"""Resolution strategy suggests data source lookup as alternative."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.cycle_reports) == 1
|
||||
report = graph.cycle_reports[0]
|
||||
assert "data source" in report.resolution_strategy.lower()
|
||||
|
||||
def test_suggested_break_edge_is_in_cycle(self):
|
||||
"""The suggested edge to break is actually part of the cycle."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-a",
|
||||
name="service-a",
|
||||
raw_references=["svc-b"],
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc-b",
|
||||
name="service-b",
|
||||
raw_references=["svc-a"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
report = graph.cycle_reports[0]
|
||||
# The suggested break edge nodes should be in the cycle
|
||||
source, target = report.suggested_break
|
||||
assert source in report.cycle
|
||||
assert target in report.cycle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: No cycles in acyclic graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoCycles:
|
||||
"""Tests that acyclic graphs report no cycles."""
|
||||
|
||||
def test_linear_chain_has_no_cycles(self):
|
||||
"""A simple linear chain has no cycles."""
|
||||
resource_a = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
resource_b = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="default/svc/app",
|
||||
name="app",
|
||||
raw_references=["ns/default"],
|
||||
)
|
||||
resource_c = make_resource(
|
||||
resource_type="kubernetes_ingress",
|
||||
unique_id="default/ingress/app",
|
||||
name="app-ingress",
|
||||
raw_references=["default/svc/app"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource_a, resource_b, resource_c])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.cycles == []
|
||||
assert graph.cycle_reports == []
|
||||
|
||||
def test_empty_graph_has_no_cycles(self):
|
||||
"""An empty graph has no cycles."""
|
||||
scan_result = make_scan_result([])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.cycles == []
|
||||
assert graph.cycle_reports == []
|
||||
|
||||
def test_standalone_resources_have_no_cycles(self):
|
||||
"""Resources with no references have no cycles."""
|
||||
resources = [
|
||||
make_resource(unique_id=f"res-{i}", name=f"res-{i}")
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
scan_result = make_scan_result(resources)
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.cycles == []
|
||||
assert graph.cycle_reports == []
|
||||
445
tests/unit/test_resolver_unresolved.py
Normal file
445
tests/unit/test_resolver_unresolved.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""Unit tests for unresolved reference handling in the DependencyResolver."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.resolver import DependencyResolver
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "default/deployments/nginx",
|
||||
name: str = "nginx",
|
||||
raw_references: list[str] | None = None,
|
||||
attributes: dict | None = None,
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
) -> 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=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes=attributes or {},
|
||||
raw_references=raw_references or [],
|
||||
)
|
||||
|
||||
|
||||
def make_scan_result(resources: list[DiscoveredResource]) -> ScanResult:
|
||||
"""Create a ScanResult from a list of resources."""
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="test-hash",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Single unresolved reference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleUnresolvedReference:
|
||||
"""Tests for a single unresolved reference creating an UnresolvedReference entry."""
|
||||
|
||||
def test_single_unresolved_reference_creates_entry(self):
|
||||
"""A raw_reference pointing to an ID not in the inventory creates an UnresolvedReference."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["nonexistent/resource/id"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.unresolved_references) == 1
|
||||
unresolved = graph.unresolved_references[0]
|
||||
assert unresolved.source_resource_id == "default/deployments/app"
|
||||
assert unresolved.referenced_id == "nonexistent/resource/id"
|
||||
|
||||
def test_unresolved_reference_source_attribute_from_attributes(self):
|
||||
"""The source_attribute is identified from the resource's attributes dict."""
|
||||
resource = make_resource(
|
||||
resource_type="windows_iis_site",
|
||||
unique_id="win/iis/sites/MySite",
|
||||
name="MySite",
|
||||
raw_references=["missing-pool-id"],
|
||||
attributes={"app_pool": "missing-pool-id", "state": "Started"},
|
||||
provider=ProviderType.WINDOWS,
|
||||
platform_category=PlatformCategory.WINDOWS,
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.unresolved_references) == 1
|
||||
assert graph.unresolved_references[0].source_attribute == "app_pool"
|
||||
|
||||
def test_unresolved_reference_fallback_to_raw_references(self):
|
||||
"""Falls back to 'raw_references' when the ref ID isn't in attributes."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["some/external/resource"],
|
||||
attributes={"replicas": "3"},
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].source_attribute == "raw_references"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multiple unresolved references from same resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleUnresolvedFromSameResource:
|
||||
"""Tests for multiple unresolved references from the same resource."""
|
||||
|
||||
def test_multiple_unresolved_from_same_resource(self):
|
||||
"""Multiple unresolved references from one resource create multiple entries."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=[
|
||||
"missing/namespace/id",
|
||||
"missing/configmap/id",
|
||||
"missing/secret/id",
|
||||
],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.unresolved_references) == 3
|
||||
referenced_ids = [u.referenced_id for u in graph.unresolved_references]
|
||||
assert "missing/namespace/id" in referenced_ids
|
||||
assert "missing/configmap/id" in referenced_ids
|
||||
assert "missing/secret/id" in referenced_ids
|
||||
|
||||
def test_all_entries_share_same_source_resource_id(self):
|
||||
"""All unresolved entries from the same resource share the source_resource_id."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["missing/ref-a", "missing/ref-b"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
for unresolved in graph.unresolved_references:
|
||||
assert unresolved.source_resource_id == "default/deployments/app"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Mix of resolved and unresolved references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMixedResolvedAndUnresolved:
|
||||
"""Tests for a mix of resolved and unresolved references."""
|
||||
|
||||
def test_resolved_creates_relationship_unresolved_creates_entry(self):
|
||||
"""Resolved references create relationships; unresolved create entries."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/default", "missing/configmap/app-config"],
|
||||
attributes={"namespace": "default"},
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# One resolved relationship
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].target_id == "ns/default"
|
||||
|
||||
# One unresolved reference
|
||||
assert len(graph.unresolved_references) == 1
|
||||
assert (
|
||||
graph.unresolved_references[0].referenced_id
|
||||
== "missing/configmap/app-config"
|
||||
)
|
||||
|
||||
def test_mixed_references_multiple_resources(self):
|
||||
"""Multiple resources with a mix of resolved and unresolved references."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/prod",
|
||||
name="prod",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="prod/deployments/web",
|
||||
name="web",
|
||||
raw_references=["ns/prod", "external/database/postgres"],
|
||||
)
|
||||
service = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="prod/services/web-svc",
|
||||
name="web-svc",
|
||||
raw_references=["ns/prod", "missing/endpoint"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment, service])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Two resolved relationships (deployment->ns, service->ns)
|
||||
assert len(graph.relationships) == 2
|
||||
|
||||
# Two unresolved references
|
||||
assert len(graph.unresolved_references) == 2
|
||||
unresolved_ids = [u.referenced_id for u in graph.unresolved_references]
|
||||
assert "external/database/postgres" in unresolved_ids
|
||||
assert "missing/endpoint" in unresolved_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: suggested_resolution is "data_source" for ID-like references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuggestedResolutionDataSource:
|
||||
"""Tests that suggested_resolution is 'data_source' for ID-like references."""
|
||||
|
||||
def test_reference_with_slash_suggests_data_source(self):
|
||||
"""A reference containing '/' is suggested as 'data_source'."""
|
||||
resource = make_resource(
|
||||
raw_references=["external/vpc/vpc-12345"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "data_source"
|
||||
|
||||
def test_reference_with_colon_suggests_data_source(self):
|
||||
"""A reference containing ':' is suggested as 'data_source'."""
|
||||
resource = make_resource(
|
||||
raw_references=["arn:aws:ec2:us-east-1:123456:instance/i-abc123"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "data_source"
|
||||
|
||||
def test_reference_with_both_slash_and_colon_suggests_data_source(self):
|
||||
"""A reference containing both '/' and ':' is suggested as 'data_source'."""
|
||||
resource = make_resource(
|
||||
raw_references=["provider:type/resource-name"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "data_source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: suggested_resolution is "variable" for simple name references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuggestedResolutionVariable:
|
||||
"""Tests that suggested_resolution is 'variable' for simple name references."""
|
||||
|
||||
def test_simple_name_suggests_variable(self):
|
||||
"""A simple name without '/' or ':' is suggested as 'variable'."""
|
||||
resource = make_resource(
|
||||
raw_references=["my-environment"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "variable"
|
||||
|
||||
def test_alphanumeric_name_suggests_variable(self):
|
||||
"""An alphanumeric name is suggested as 'variable'."""
|
||||
resource = make_resource(
|
||||
raw_references=["production123"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "variable"
|
||||
|
||||
def test_name_with_dashes_and_underscores_suggests_variable(self):
|
||||
"""A name with dashes and underscores (but no / or :) is 'variable'."""
|
||||
resource = make_resource(
|
||||
raw_references=["my_app-pool-name"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert graph.unresolved_references[0].suggested_resolution == "variable"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Unresolved references don't create graph edges or relationships
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnresolvedDontCreateEdges:
|
||||
"""Tests that unresolved references don't create graph edges or relationships."""
|
||||
|
||||
def test_unresolved_reference_creates_no_relationship(self):
|
||||
"""An unresolved reference does not create a ResourceRelationship."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["nonexistent/resource"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(graph.relationships) == 0
|
||||
|
||||
def test_unresolved_reference_does_not_affect_topological_order(self):
|
||||
"""Unresolved references don't add extra nodes to the topological order."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["nonexistent/resource"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# Only the actual resource should be in the topological order
|
||||
assert graph.topological_order == ["default/deployments/app"]
|
||||
|
||||
def test_unresolved_does_not_block_resolved_relationships(self):
|
||||
"""Unresolved references don't prevent resolved references from working."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["ns/default", "nonexistent/configmap"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([namespace, deployment])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
graph = resolver.resolve()
|
||||
|
||||
# The resolved relationship still works
|
||||
assert len(graph.relationships) == 1
|
||||
assert graph.relationships[0].source_id == "default/deployments/app"
|
||||
assert graph.relationships[0].target_id == "ns/default"
|
||||
|
||||
# Topological order is correct
|
||||
order = graph.topological_order
|
||||
assert order.index("ns/default") < order.index("default/deployments/app")
|
||||
|
||||
# Unresolved reference is tracked
|
||||
assert len(graph.unresolved_references) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Warning logging for unresolved references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnresolvedReferenceLogging:
|
||||
"""Tests that warnings are logged for unresolved references."""
|
||||
|
||||
def test_warning_logged_for_unresolved_reference(self, caplog):
|
||||
"""A warning is logged for each unresolved reference."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["missing/resource/id"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="iac_reverse.resolver.resolver"):
|
||||
graph = resolver.resolve()
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
record = caplog.records[0]
|
||||
assert record.levelname == "WARNING"
|
||||
assert "missing/resource/id" in record.message
|
||||
assert "default/deployments/app" in record.message
|
||||
|
||||
def test_multiple_warnings_logged_for_multiple_unresolved(self, caplog):
|
||||
"""A warning is logged for each unresolved reference."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="default/deployments/app",
|
||||
name="app",
|
||||
raw_references=["missing/ref-a", "missing/ref-b"],
|
||||
)
|
||||
|
||||
scan_result = make_scan_result([resource])
|
||||
resolver = DependencyResolver(scan_result)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="iac_reverse.resolver.resolver"):
|
||||
graph = resolver.resolve()
|
||||
|
||||
warning_messages = [r.message for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert len(warning_messages) == 2
|
||||
assert any("missing/ref-a" in msg for msg in warning_messages)
|
||||
assert any("missing/ref-b" in msg for msg in warning_messages)
|
||||
343
tests/unit/test_resource_merger.py
Normal file
343
tests/unit/test_resource_merger.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Unit tests for the ResourceMerger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.generator import ResourceMerger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
name: str = "nginx",
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "default/deployments/nginx",
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture: CpuArchitecture = CpuArchitecture.AARCH64,
|
||||
attributes: dict | 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://api.local:6443",
|
||||
attributes=attributes or {},
|
||||
raw_references=[],
|
||||
)
|
||||
|
||||
|
||||
def make_scan_result(resources: list[DiscoveredResource]) -> ScanResult:
|
||||
"""Create a ScanResult wrapping the given resources."""
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash="abc123",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResourceMergerSingleResult:
|
||||
"""Single scan result passes through unchanged."""
|
||||
|
||||
def test_single_scan_result_passes_through_unchanged(self):
|
||||
"""Resources from a single scan result are returned as-is."""
|
||||
resources = [
|
||||
make_resource(name="nginx", unique_id="k8s/nginx"),
|
||||
make_resource(name="redis", unique_id="k8s/redis"),
|
||||
]
|
||||
scan_result = make_scan_result(resources)
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([scan_result])
|
||||
|
||||
assert len(merged) == 2
|
||||
assert merged[0].name == "nginx"
|
||||
assert merged[1].name == "redis"
|
||||
|
||||
def test_empty_scan_result_returns_empty_list(self):
|
||||
"""An empty scan result produces an empty merged list."""
|
||||
scan_result = make_scan_result([])
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([scan_result])
|
||||
|
||||
assert merged == []
|
||||
|
||||
|
||||
class TestResourceMergerNoConflicts:
|
||||
"""Two scan results with no conflicts merge cleanly."""
|
||||
|
||||
def test_two_results_different_names_merge_cleanly(self):
|
||||
"""Resources with different names from different providers merge without prefixing."""
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="k8s/nginx",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="redis",
|
||||
unique_id="docker/redis",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
])
|
||||
|
||||
assert len(merged) == 2
|
||||
names = {r.name for r in merged}
|
||||
assert names == {"nginx", "redis"}
|
||||
|
||||
def test_same_name_same_provider_no_conflict(self):
|
||||
"""Resources with the same name from the same provider are not conflicts."""
|
||||
resources = [
|
||||
make_resource(name="nginx", unique_id="k8s/ns1/nginx"),
|
||||
make_resource(name="nginx", unique_id="k8s/ns2/nginx"),
|
||||
]
|
||||
scan_result = make_scan_result(resources)
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([scan_result])
|
||||
|
||||
assert len(merged) == 2
|
||||
# Both keep original name since they're from the same provider
|
||||
assert all(r.name == "nginx" for r in merged)
|
||||
|
||||
|
||||
class TestResourceMergerConflictResolution:
|
||||
"""Two scan results with name conflicts get provider-prefixed names."""
|
||||
|
||||
def test_conflicting_names_get_provider_prefix(self):
|
||||
"""Resources with the same name from different providers get prefixed."""
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="k8s/nginx",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="docker/nginx",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
])
|
||||
|
||||
assert len(merged) == 2
|
||||
names = {r.name for r in merged}
|
||||
assert "kubernetes_nginx" in names
|
||||
assert "docker_swarm_nginx" in names
|
||||
|
||||
def test_non_conflicting_names_unchanged_alongside_conflicts(self):
|
||||
"""Non-conflicting resources keep their original names even when conflicts exist."""
|
||||
k8s_resources = [
|
||||
make_resource(name="nginx", unique_id="k8s/nginx", provider=ProviderType.KUBERNETES),
|
||||
make_resource(name="postgres", unique_id="k8s/postgres", provider=ProviderType.KUBERNETES),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="docker/nginx",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
])
|
||||
|
||||
assert len(merged) == 3
|
||||
names = {r.name for r in merged}
|
||||
assert "kubernetes_nginx" in names
|
||||
assert "docker_swarm_nginx" in names
|
||||
assert "postgres" in names
|
||||
|
||||
|
||||
class TestResourceMergerPreservesAttributes:
|
||||
"""Provider-specific attributes are preserved."""
|
||||
|
||||
def test_attributes_preserved_after_merge(self):
|
||||
"""Provider-specific attributes remain unchanged after merging."""
|
||||
k8s_attrs = {"namespace": "default", "replicas": 3, "image": "nginx:1.25"}
|
||||
docker_attrs = {"mode": "replicated", "replicas": 2, "network": "overlay"}
|
||||
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="k8s/nginx",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
attributes=k8s_attrs,
|
||||
),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="nginx",
|
||||
unique_id="docker/nginx",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
attributes=docker_attrs,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
])
|
||||
|
||||
k8s_merged = next(r for r in merged if r.name == "kubernetes_nginx")
|
||||
docker_merged = next(r for r in merged if r.name == "docker_swarm_nginx")
|
||||
|
||||
assert k8s_merged.attributes == k8s_attrs
|
||||
assert docker_merged.attributes == docker_attrs
|
||||
|
||||
def test_provider_and_metadata_preserved(self):
|
||||
"""Provider type, platform category, and architecture are preserved."""
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="app",
|
||||
unique_id="k8s/app",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
),
|
||||
]
|
||||
harvester_resources = [
|
||||
make_resource(
|
||||
name="app",
|
||||
unique_id="harvester/app",
|
||||
provider=ProviderType.HARVESTER,
|
||||
platform_category=PlatformCategory.HCI,
|
||||
architecture=CpuArchitecture.AMD64,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(harvester_resources),
|
||||
])
|
||||
|
||||
k8s_merged = next(r for r in merged if r.name == "kubernetes_app")
|
||||
harvester_merged = next(r for r in merged if r.name == "harvester_app")
|
||||
|
||||
assert k8s_merged.provider == ProviderType.KUBERNETES
|
||||
assert k8s_merged.platform_category == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert k8s_merged.architecture == CpuArchitecture.AARCH64
|
||||
|
||||
assert harvester_merged.provider == ProviderType.HARVESTER
|
||||
assert harvester_merged.platform_category == PlatformCategory.HCI
|
||||
assert harvester_merged.architecture == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
class TestResourceMergerBothAppearInOutput:
|
||||
"""Resources from different providers with same name both appear in output."""
|
||||
|
||||
def test_both_conflicting_resources_present(self):
|
||||
"""Both resources with the same name from different providers appear in output."""
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="webserver",
|
||||
unique_id="k8s/webserver",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
attributes={"replicas": 3},
|
||||
),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="webserver",
|
||||
unique_id="docker/webserver",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
attributes={"mode": "global"},
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
])
|
||||
|
||||
assert len(merged) == 2
|
||||
unique_ids = {r.unique_id for r in merged}
|
||||
assert "k8s/webserver" in unique_ids
|
||||
assert "docker/webserver" in unique_ids
|
||||
|
||||
def test_three_providers_same_name_all_appear(self):
|
||||
"""Three providers with the same resource name all appear with prefixes."""
|
||||
k8s_resources = [
|
||||
make_resource(
|
||||
name="monitor",
|
||||
unique_id="k8s/monitor",
|
||||
provider=ProviderType.KUBERNETES,
|
||||
),
|
||||
]
|
||||
docker_resources = [
|
||||
make_resource(
|
||||
name="monitor",
|
||||
unique_id="docker/monitor",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
),
|
||||
]
|
||||
harvester_resources = [
|
||||
make_resource(
|
||||
name="monitor",
|
||||
unique_id="harvester/monitor",
|
||||
provider=ProviderType.HARVESTER,
|
||||
platform_category=PlatformCategory.HCI,
|
||||
architecture=CpuArchitecture.AMD64,
|
||||
),
|
||||
]
|
||||
|
||||
merger = ResourceMerger()
|
||||
merged = merger.merge([
|
||||
make_scan_result(k8s_resources),
|
||||
make_scan_result(docker_resources),
|
||||
make_scan_result(harvester_resources),
|
||||
])
|
||||
|
||||
assert len(merged) == 3
|
||||
names = {r.name for r in merged}
|
||||
assert "kubernetes_monitor" in names
|
||||
assert "docker_swarm_monitor" in names
|
||||
assert "harvester_monitor" in names
|
||||
115
tests/unit/test_sanitize.py
Normal file
115
tests/unit/test_sanitize.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for identifier sanitization."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.generator.sanitize import sanitize_identifier
|
||||
|
||||
|
||||
TERRAFORM_IDENTIFIER_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
|
||||
|
||||
class TestSanitizeIdentifierNormalNames:
|
||||
def test_simple_name_passes_through(self):
|
||||
assert sanitize_identifier("nginx") == "nginx"
|
||||
|
||||
def test_name_with_underscores_passes_through(self):
|
||||
assert sanitize_identifier("my_app") == "my_app"
|
||||
|
||||
def test_alphanumeric_name_passes_through(self):
|
||||
assert sanitize_identifier("app123") == "app123"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierHyphens:
|
||||
def test_hyphens_replaced_with_underscore(self):
|
||||
assert sanitize_identifier("my-app") == "my_app"
|
||||
|
||||
def test_multiple_hyphens(self):
|
||||
assert sanitize_identifier("my-cool-app") == "my_cool_app"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierLeadingDigits:
|
||||
def test_leading_digit_gets_underscore_prefix(self):
|
||||
assert sanitize_identifier("123abc") == "_123abc"
|
||||
|
||||
def test_all_digits(self):
|
||||
result = sanitize_identifier("12345")
|
||||
assert result == "_12345"
|
||||
assert TERRAFORM_IDENTIFIER_RE.match(result)
|
||||
|
||||
|
||||
class TestSanitizeIdentifierSpaces:
|
||||
def test_spaces_replaced_with_underscore(self):
|
||||
assert sanitize_identifier("my app") == "my_app"
|
||||
|
||||
def test_multiple_spaces_collapse(self):
|
||||
assert sanitize_identifier("my app") == "my_app"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierUnicode:
|
||||
def test_unicode_replaced(self):
|
||||
# é is replaced with underscore, trailing underscore preserved
|
||||
assert sanitize_identifier("café") == "caf_"
|
||||
|
||||
def test_all_unicode(self):
|
||||
result = sanitize_identifier("日本語")
|
||||
assert result == "_resource"
|
||||
|
||||
def test_emoji_replaced(self):
|
||||
result = sanitize_identifier("app🚀name")
|
||||
assert result == "app_name"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierEmptyAndSpecial:
|
||||
def test_empty_string_returns_resource(self):
|
||||
assert sanitize_identifier("") == "_resource"
|
||||
|
||||
def test_all_special_chars_returns_resource(self):
|
||||
assert sanitize_identifier("@#$%^&*") == "_resource"
|
||||
|
||||
def test_single_special_char(self):
|
||||
assert sanitize_identifier("!") == "_resource"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierConsecutiveSpecialChars:
|
||||
def test_multiple_consecutive_special_chars_collapse(self):
|
||||
assert sanitize_identifier("a---b") == "a_b"
|
||||
|
||||
def test_mixed_special_chars_collapse(self):
|
||||
assert sanitize_identifier("a-.-b") == "a_b"
|
||||
|
||||
def test_leading_special_chars_collapse(self):
|
||||
# Leading hyphens become underscore (valid identifier start)
|
||||
result = sanitize_identifier("---abc")
|
||||
assert result == "_abc"
|
||||
|
||||
|
||||
class TestSanitizeIdentifierAlwaysValid:
|
||||
"""Verify the result always matches the Terraform identifier regex."""
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"nginx",
|
||||
"my-app",
|
||||
"123abc",
|
||||
"my app",
|
||||
"café",
|
||||
"",
|
||||
"@#$%^&*",
|
||||
"a---b",
|
||||
"___",
|
||||
"hello_world_123",
|
||||
"日本語テスト",
|
||||
" leading spaces",
|
||||
"trailing ",
|
||||
"MixedCase-Name_123",
|
||||
"a",
|
||||
"_",
|
||||
"0",
|
||||
])
|
||||
def test_result_matches_terraform_regex(self, name):
|
||||
result = sanitize_identifier(name)
|
||||
assert TERRAFORM_IDENTIFIER_RE.match(result), (
|
||||
f"sanitize_identifier({name!r}) = {result!r} does not match "
|
||||
f"Terraform identifier pattern"
|
||||
)
|
||||
193
tests/unit/test_scan_profile_validation.py
Normal file
193
tests/unit/test_scan_profile_validation.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for ScanProfile validation logic."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
MAX_RESOURCE_TYPE_FILTERS,
|
||||
PROVIDER_SUPPORTED_RESOURCE_TYPES,
|
||||
ProviderType,
|
||||
ScanProfile,
|
||||
)
|
||||
|
||||
|
||||
class TestScanProfileValidationCredentials:
|
||||
"""Tests for credentials validation."""
|
||||
|
||||
def test_empty_credentials_returns_error(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={},
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert any("credentials must not be empty" in e for e in errors)
|
||||
|
||||
def test_non_empty_credentials_passes(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={"kubeconfig_path": "/home/user/.kube/config"},
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestScanProfileValidationResourceTypeFilters:
|
||||
"""Tests for resource_type_filters count limit."""
|
||||
|
||||
def test_none_filters_passes(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
credentials={"host": "localhost"},
|
||||
resource_type_filters=None,
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_empty_filters_passes(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
credentials={"host": "localhost"},
|
||||
resource_type_filters=[],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_filters_at_max_limit_passes(self):
|
||||
# Use valid resource types repeated to reach the limit
|
||||
valid_types = PROVIDER_SUPPORTED_RESOURCE_TYPES[ProviderType.WINDOWS]
|
||||
filters = (valid_types * (MAX_RESOURCE_TYPE_FILTERS // len(valid_types) + 1))[
|
||||
:MAX_RESOURCE_TYPE_FILTERS
|
||||
]
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.WINDOWS,
|
||||
credentials={"host": "win01"},
|
||||
resource_type_filters=filters,
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_filters_exceeding_max_limit_returns_error(self):
|
||||
# Use valid resource types repeated to exceed the limit
|
||||
valid_types = PROVIDER_SUPPORTED_RESOURCE_TYPES[ProviderType.WINDOWS]
|
||||
filters = (valid_types * (MAX_RESOURCE_TYPE_FILTERS // len(valid_types) + 1))[
|
||||
: MAX_RESOURCE_TYPE_FILTERS + 1
|
||||
]
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.WINDOWS,
|
||||
credentials={"host": "win01"},
|
||||
resource_type_filters=filters,
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert any("at most" in e and "200" in e for e in errors)
|
||||
|
||||
def test_max_limit_is_200(self):
|
||||
assert MAX_RESOURCE_TYPE_FILTERS == 200
|
||||
|
||||
|
||||
class TestScanProfileValidationResourceTypeSupport:
|
||||
"""Tests for resource type validation against provider's supported types."""
|
||||
|
||||
def test_valid_resource_types_for_kubernetes(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={"kubeconfig_path": "/path"},
|
||||
resource_type_filters=["kubernetes_deployment", "kubernetes_service"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_valid_resource_types_for_docker_swarm(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
credentials={"host": "localhost"},
|
||||
resource_type_filters=["docker_service", "docker_network"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
def test_unsupported_resource_type_returns_error(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={"kubeconfig_path": "/path"},
|
||||
resource_type_filters=["kubernetes_deployment", "invalid_type"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert any("unsupported resource types" in e for e in errors)
|
||||
assert any("invalid_type" in e for e in errors)
|
||||
|
||||
def test_cross_provider_resource_type_returns_error(self):
|
||||
"""A resource type valid for one provider is invalid for another."""
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.KUBERNETES,
|
||||
credentials={"kubeconfig_path": "/path"},
|
||||
resource_type_filters=["docker_service"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert any("unsupported resource types" in e for e in errors)
|
||||
assert any("docker_service" in e for e in errors)
|
||||
|
||||
def test_multiple_unsupported_types_listed_in_error(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.SYNOLOGY,
|
||||
credentials={"host": "nas01"},
|
||||
resource_type_filters=["fake_type_a", "fake_type_b"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert any("fake_type_a" in e and "fake_type_b" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("provider", list(ProviderType))
|
||||
def test_all_providers_have_supported_types(self, provider):
|
||||
"""Every provider must have at least one supported resource type."""
|
||||
assert provider in PROVIDER_SUPPORTED_RESOURCE_TYPES
|
||||
assert len(PROVIDER_SUPPORTED_RESOURCE_TYPES[provider]) > 0
|
||||
|
||||
@pytest.mark.parametrize("provider", list(ProviderType))
|
||||
def test_all_supported_types_pass_validation(self, provider):
|
||||
"""All listed supported types for a provider should pass validation."""
|
||||
supported = PROVIDER_SUPPORTED_RESOURCE_TYPES[provider]
|
||||
profile = ScanProfile(
|
||||
provider=provider,
|
||||
credentials={"key": "value"},
|
||||
resource_type_filters=supported,
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestScanProfileValidationMultipleErrors:
|
||||
"""Tests that all validation errors are returned in a single response."""
|
||||
|
||||
def test_empty_credentials_and_too_many_filters(self):
|
||||
filters = ["invalid_type"] * (MAX_RESOURCE_TYPE_FILTERS + 1)
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.HARVESTER,
|
||||
credentials={},
|
||||
resource_type_filters=filters,
|
||||
)
|
||||
errors = profile.validate()
|
||||
# Should have at least: credentials error, too many filters, unsupported types
|
||||
assert len(errors) >= 3
|
||||
assert any("credentials" in e for e in errors)
|
||||
assert any("at most" in e for e in errors)
|
||||
assert any("unsupported" in e for e in errors)
|
||||
|
||||
def test_empty_credentials_and_unsupported_types(self):
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.BARE_METAL,
|
||||
credentials={},
|
||||
resource_type_filters=["nonexistent_type"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
assert len(errors) >= 2
|
||||
assert any("credentials" in e for e in errors)
|
||||
assert any("unsupported" in e for e in errors)
|
||||
|
||||
def test_no_short_circuit_on_first_error(self):
|
||||
"""Validation must not stop at the first error found."""
|
||||
profile = ScanProfile(
|
||||
provider=ProviderType.WINDOWS,
|
||||
credentials={},
|
||||
resource_type_filters=["totally_fake_resource"],
|
||||
)
|
||||
errors = profile.validate()
|
||||
# Both credentials and unsupported type errors should be present
|
||||
assert len(errors) >= 2
|
||||
480
tests/unit/test_scanner.py
Normal file
480
tests/unit/test_scanner.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""Unit tests for the Scanner orchestrator."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
from iac_reverse.scanner.scanner import (
|
||||
AuthenticationError,
|
||||
ConnectionLostError,
|
||||
Scanner,
|
||||
ScanTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_profile(**overrides) -> ScanProfile:
|
||||
"""Create a valid ScanProfile with sensible defaults."""
|
||||
defaults = {
|
||||
"provider": ProviderType.KUBERNETES,
|
||||
"credentials": {"kubeconfig_path": "/home/user/.kube/config"},
|
||||
"endpoints": ["https://k8s-api.local:6443"],
|
||||
"resource_type_filters": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ScanProfile(**defaults)
|
||||
|
||||
|
||||
def make_resource(resource_type: str = "kubernetes_deployment", name: str = "nginx") -> DiscoveredResource:
|
||||
"""Create a sample DiscoveredResource."""
|
||||
return DiscoveredResource(
|
||||
resource_type=resource_type,
|
||||
unique_id=f"apps/v1/{resource_type}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes={"replicas": 3},
|
||||
raw_references=[],
|
||||
)
|
||||
|
||||
|
||||
class MockPlugin(ProviderPlugin):
|
||||
"""A mock provider plugin for testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supported_types=None,
|
||||
endpoints=None,
|
||||
auth_error=None,
|
||||
discover_result=None,
|
||||
discover_side_effect=None,
|
||||
):
|
||||
self._supported_types = supported_types or [
|
||||
"kubernetes_deployment",
|
||||
"kubernetes_service",
|
||||
"kubernetes_ingress",
|
||||
]
|
||||
self._endpoints = endpoints or ["https://k8s-api.local:6443"]
|
||||
self._auth_error = auth_error
|
||||
self._discover_result = discover_result
|
||||
self._discover_side_effect = discover_side_effect
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
if self._auth_error:
|
||||
raise self._auth_error
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return self._endpoints
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return self._supported_types
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AARCH64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback=None,
|
||||
) -> ScanResult:
|
||||
if self._discover_side_effect:
|
||||
raise self._discover_side_effect
|
||||
|
||||
if self._discover_result:
|
||||
return self._discover_result
|
||||
|
||||
# Default: return one resource per type and invoke progress callback
|
||||
resources = []
|
||||
for i, rt in enumerate(resource_types):
|
||||
resources.append(make_resource(resource_type=rt, name=f"{rt}_instance"))
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
ScanProgress(
|
||||
current_resource_type=rt,
|
||||
resources_discovered=len(resources),
|
||||
resource_types_completed=i + 1,
|
||||
total_resource_types=len(resource_types),
|
||||
)
|
||||
)
|
||||
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Successful scan flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuccessfulScan:
|
||||
"""Tests for the happy path scan flow."""
|
||||
|
||||
def test_scan_returns_all_discovered_resources(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 3
|
||||
assert result.is_partial is False
|
||||
assert result.scan_timestamp != ""
|
||||
assert result.profile_hash != ""
|
||||
|
||||
def test_scan_with_resource_type_filters(self):
|
||||
profile = make_profile(
|
||||
resource_type_filters=["kubernetes_deployment", "kubernetes_service"]
|
||||
)
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 2
|
||||
resource_types = [r.resource_type for r in result.resources]
|
||||
assert "kubernetes_deployment" in resource_types
|
||||
assert "kubernetes_service" in resource_types
|
||||
|
||||
def test_scan_uses_plugin_endpoints_when_profile_has_none(self):
|
||||
profile = make_profile(endpoints=None)
|
||||
plugin = MockPlugin(endpoints=["https://fallback.local:6443"])
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 3
|
||||
|
||||
def test_scan_uses_profile_endpoints_when_provided(self):
|
||||
profile = make_profile(endpoints=["https://custom.local:6443"])
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
# The plugin's discover_resources will be called with profile endpoints
|
||||
result = scanner.scan()
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Authentication failure handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticationFailure:
|
||||
"""Tests for authentication error handling."""
|
||||
|
||||
def test_auth_failure_raises_authentication_error(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin(auth_error=RuntimeError("Invalid token"))
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
scanner.scan()
|
||||
|
||||
assert "kubernetes" in exc_info.value.provider_name
|
||||
assert "Invalid token" in exc_info.value.reason
|
||||
|
||||
def test_auth_error_contains_provider_name(self):
|
||||
profile = make_profile(provider=ProviderType.DOCKER_SWARM)
|
||||
plugin = MockPlugin(auth_error=RuntimeError("Connection refused"))
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
scanner.scan()
|
||||
|
||||
assert exc_info.value.provider_name == "docker_swarm"
|
||||
|
||||
def test_auth_error_contains_reason(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin(auth_error=RuntimeError("Certificate expired"))
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
scanner.scan()
|
||||
|
||||
assert "Certificate expired" in exc_info.value.reason
|
||||
|
||||
def test_invalid_profile_raises_value_error(self):
|
||||
profile = make_profile(credentials={})
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid scan profile"):
|
||||
scanner.scan()
|
||||
|
||||
def test_no_plugin_raises_value_error(self):
|
||||
profile = make_profile()
|
||||
scanner = Scanner(profile, plugin=None)
|
||||
|
||||
with pytest.raises(ValueError, match="No provider plugin"):
|
||||
scanner.scan()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Progress callback invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProgressCallback:
|
||||
"""Tests for progress reporting."""
|
||||
|
||||
def test_progress_callback_invoked_per_resource_type(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
progress_updates = []
|
||||
scanner.scan(progress_callback=progress_updates.append)
|
||||
|
||||
# Should have one progress update per resource type (3 types)
|
||||
assert len(progress_updates) == 3
|
||||
|
||||
def test_progress_callback_contains_correct_counts(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
progress_updates = []
|
||||
scanner.scan(progress_callback=progress_updates.append)
|
||||
|
||||
# Last update should show all types completed
|
||||
last = progress_updates[-1]
|
||||
assert last.resource_types_completed == 3
|
||||
assert last.total_resource_types == 3
|
||||
|
||||
def test_progress_callback_shows_incremental_completion(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
progress_updates = []
|
||||
scanner.scan(progress_callback=progress_updates.append)
|
||||
|
||||
for i, update in enumerate(progress_updates):
|
||||
assert update.resource_types_completed == i + 1
|
||||
|
||||
def test_scan_works_without_progress_callback(self):
|
||||
profile = make_profile()
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
# Should not raise
|
||||
result = scanner.scan(progress_callback=None)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Retry logic on transient errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
"""Tests for retry behavior on transient errors."""
|
||||
|
||||
def test_retries_on_transient_error_then_succeeds(self):
|
||||
profile = make_profile()
|
||||
call_count = {"n": 0}
|
||||
expected_result = ScanResult(
|
||||
resources=[make_resource()],
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
class RetryPlugin(MockPlugin):
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback=None):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] < 3:
|
||||
raise RuntimeError("Transient network error")
|
||||
return expected_result
|
||||
|
||||
plugin = RetryPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with patch("iac_reverse.scanner.scanner.time.sleep"):
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert call_count["n"] == 3
|
||||
|
||||
def test_returns_error_result_after_max_retries_exhausted(self):
|
||||
profile = make_profile()
|
||||
|
||||
class AlwaysFailPlugin(MockPlugin):
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback=None):
|
||||
raise RuntimeError("Persistent failure")
|
||||
|
||||
plugin = AlwaysFailPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with patch("iac_reverse.scanner.scanner.time.sleep"):
|
||||
result = scanner.scan()
|
||||
|
||||
assert result.is_partial is True
|
||||
assert len(result.errors) > 0
|
||||
assert "Persistent failure" in result.errors[0]
|
||||
|
||||
def test_exponential_backoff_timing(self):
|
||||
profile = make_profile()
|
||||
sleep_calls = []
|
||||
|
||||
class FailPlugin(MockPlugin):
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback=None):
|
||||
raise RuntimeError("Transient error")
|
||||
|
||||
plugin = FailPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with patch("iac_reverse.scanner.scanner.time.sleep", side_effect=lambda s: sleep_calls.append(s)):
|
||||
scanner.scan()
|
||||
|
||||
# Should have 3 sleep calls (retries 0, 1, 2 → backoff 1, 2, 4)
|
||||
assert len(sleep_calls) == 3
|
||||
assert sleep_calls[0] == 1.0
|
||||
assert sleep_calls[1] == 2.0
|
||||
assert sleep_calls[2] == 4.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Partial inventory on connection loss
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnectionLoss:
|
||||
"""Tests for partial inventory return on connection loss."""
|
||||
|
||||
def test_connection_error_returns_partial_result(self):
|
||||
profile = make_profile()
|
||||
|
||||
class ConnectionLossPlugin(MockPlugin):
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback=None):
|
||||
raise ConnectionError("Connection reset by peer")
|
||||
|
||||
plugin = ConnectionLossPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(ConnectionLostError) as exc_info:
|
||||
scanner.scan()
|
||||
|
||||
partial = exc_info.value.partial_result
|
||||
assert partial.is_partial is True
|
||||
assert len(partial.errors) > 0
|
||||
|
||||
def test_connection_lost_error_has_partial_result(self):
|
||||
profile = make_profile()
|
||||
partial = ScanResult(
|
||||
resources=[make_resource()],
|
||||
warnings=["partial"],
|
||||
errors=["lost connection"],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
is_partial=True,
|
||||
)
|
||||
|
||||
class RaisesConnectionLost(MockPlugin):
|
||||
def discover_resources(self, endpoints, resource_types, progress_callback=None):
|
||||
raise ConnectionLostError(partial_result=partial)
|
||||
|
||||
plugin = RaisesConnectionLost()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
with pytest.raises(ConnectionLostError) as exc_info:
|
||||
scanner.scan()
|
||||
|
||||
assert exc_info.value.partial_result.is_partial is True
|
||||
assert len(exc_info.value.partial_result.resources) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Warning for unsupported resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnsupportedResourceTypes:
|
||||
"""Tests for warning logging on unsupported resource types."""
|
||||
|
||||
def test_unsupported_types_generate_warnings(self):
|
||||
profile = make_profile(
|
||||
resource_type_filters=[
|
||||
"kubernetes_deployment",
|
||||
"nonexistent_type",
|
||||
"another_fake_type",
|
||||
]
|
||||
)
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
# Should have warnings for the 2 unsupported types
|
||||
unsupported_warnings = [
|
||||
w for w in result.warnings if "Unsupported resource type" in w
|
||||
]
|
||||
assert len(unsupported_warnings) == 2
|
||||
assert "nonexistent_type" in unsupported_warnings[0]
|
||||
assert "another_fake_type" in unsupported_warnings[1]
|
||||
|
||||
def test_unsupported_types_do_not_block_supported_types(self):
|
||||
profile = make_profile(
|
||||
resource_type_filters=[
|
||||
"kubernetes_deployment",
|
||||
"nonexistent_type",
|
||||
]
|
||||
)
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
# Should still discover the supported type
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "kubernetes_deployment"
|
||||
|
||||
def test_all_unsupported_types_returns_empty_resources(self):
|
||||
profile = make_profile(
|
||||
resource_type_filters=["fake_type_1", "fake_type_2"]
|
||||
)
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.warnings) == 2
|
||||
|
||||
def test_warning_includes_provider_name(self):
|
||||
profile = make_profile(
|
||||
resource_type_filters=["unsupported_thing"]
|
||||
)
|
||||
plugin = MockPlugin()
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert "kubernetes" in result.warnings[0]
|
||||
234
tests/unit/test_scanner_filtering.py
Normal file
234
tests/unit/test_scanner_filtering.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Unit tests for Scanner resource type filtering behavior.
|
||||
|
||||
Validates Requirements 6.2 and 6.3:
|
||||
- 6.2: When resource type filters are specified, discover only those types
|
||||
- 6.3: When no resource type filters are specified, discover all supported types
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanProfile,
|
||||
ScanProgress,
|
||||
ScanResult,
|
||||
)
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
from iac_reverse.scanner.scanner import Scanner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_profile(**overrides) -> ScanProfile:
|
||||
"""Create a valid ScanProfile with sensible defaults."""
|
||||
defaults = {
|
||||
"provider": ProviderType.KUBERNETES,
|
||||
"credentials": {"kubeconfig_path": "/home/user/.kube/config"},
|
||||
"endpoints": ["https://k8s-api.local:6443"],
|
||||
"resource_type_filters": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ScanProfile(**defaults)
|
||||
|
||||
|
||||
def make_resource(resource_type: str, name: str = "instance") -> DiscoveredResource:
|
||||
"""Create a sample DiscoveredResource."""
|
||||
return DiscoveredResource(
|
||||
resource_type=resource_type,
|
||||
unique_id=f"{resource_type}/{name}",
|
||||
name=name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes={},
|
||||
raw_references=[],
|
||||
)
|
||||
|
||||
|
||||
class FakePlugin(ProviderPlugin):
|
||||
"""A fake plugin that tracks which resource types were requested."""
|
||||
|
||||
def __init__(self, supported_types: list[str]):
|
||||
self._supported_types = supported_types
|
||||
self.discovered_types: list[str] = []
|
||||
|
||||
def authenticate(self, credentials: dict[str, str]) -> None:
|
||||
pass
|
||||
|
||||
def get_platform_category(self) -> PlatformCategory:
|
||||
return PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
|
||||
def list_endpoints(self) -> list[str]:
|
||||
return ["https://k8s-api.local:6443"]
|
||||
|
||||
def list_supported_resource_types(self) -> list[str]:
|
||||
return self._supported_types
|
||||
|
||||
def detect_architecture(self, endpoint: str) -> CpuArchitecture:
|
||||
return CpuArchitecture.AARCH64
|
||||
|
||||
def discover_resources(
|
||||
self,
|
||||
endpoints: list[str],
|
||||
resource_types: list[str],
|
||||
progress_callback=None,
|
||||
) -> ScanResult:
|
||||
self.discovered_types = resource_types
|
||||
resources = [make_resource(rt) for rt in resource_types]
|
||||
return ScanResult(
|
||||
resources=resources,
|
||||
warnings=[],
|
||||
errors=[],
|
||||
scan_timestamp="",
|
||||
profile_hash="",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Requirement 6.2 - Filters specified, only those types discovered
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilteredResourceTypes:
|
||||
"""Requirement 6.2: When filters are specified, discover only listed types."""
|
||||
|
||||
def test_single_filter_discovers_only_that_type(self):
|
||||
"""Only the single filtered type should be discovered."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service", "kubernetes_ingress"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(resource_type_filters=["kubernetes_deployment"])
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "kubernetes_deployment"
|
||||
assert plugin.discovered_types == ["kubernetes_deployment"]
|
||||
|
||||
def test_multiple_filters_discovers_only_those_types(self):
|
||||
"""Only the filtered types should be discovered, not all supported."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service", "kubernetes_ingress"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(
|
||||
resource_type_filters=["kubernetes_deployment", "kubernetes_service"]
|
||||
)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 2
|
||||
discovered_types = {r.resource_type for r in result.resources}
|
||||
assert discovered_types == {"kubernetes_deployment", "kubernetes_service"}
|
||||
assert "kubernetes_ingress" not in plugin.discovered_types
|
||||
|
||||
def test_unsupported_types_in_filter_are_skipped_with_warnings(self):
|
||||
"""Unsupported types in the filter list produce warnings and are skipped."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(
|
||||
resource_type_filters=[
|
||||
"kubernetes_deployment",
|
||||
"nonexistent_type",
|
||||
"another_fake",
|
||||
]
|
||||
)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
# Only the supported type should be discovered
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "kubernetes_deployment"
|
||||
|
||||
# Warnings for unsupported types
|
||||
assert len(result.warnings) == 2
|
||||
warning_text = " ".join(result.warnings)
|
||||
assert "nonexistent_type" in warning_text
|
||||
assert "another_fake" in warning_text
|
||||
|
||||
def test_empty_filter_list_results_in_no_resources(self):
|
||||
"""An empty filter list means no types are requested, so nothing is discovered."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service", "kubernetes_ingress"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(resource_type_filters=[])
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert plugin.discovered_types == []
|
||||
|
||||
def test_all_filters_unsupported_results_in_no_resources(self):
|
||||
"""When all filtered types are unsupported, no resources are discovered."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(
|
||||
resource_type_filters=["fake_type_a", "fake_type_b"]
|
||||
)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.warnings) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Requirement 6.3 - No filters, discover all supported types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoFilterAllTypes:
|
||||
"""Requirement 6.3: When no filters specified, discover all supported types."""
|
||||
|
||||
def test_no_filters_discovers_all_supported_types(self):
|
||||
"""With resource_type_filters=None, all supported types are discovered."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service", "kubernetes_ingress"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(resource_type_filters=None)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 3
|
||||
discovered_types = {r.resource_type for r in result.resources}
|
||||
assert discovered_types == set(supported)
|
||||
assert plugin.discovered_types == supported
|
||||
|
||||
def test_no_filters_with_many_supported_types(self):
|
||||
"""All supported types are passed to discover_resources when no filter."""
|
||||
supported = [
|
||||
"kubernetes_deployment",
|
||||
"kubernetes_service",
|
||||
"kubernetes_ingress",
|
||||
"kubernetes_config_map",
|
||||
"kubernetes_persistent_volume",
|
||||
"kubernetes_namespace",
|
||||
]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(resource_type_filters=None)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert len(result.resources) == 6
|
||||
assert plugin.discovered_types == supported
|
||||
|
||||
def test_no_filters_produces_no_warnings(self):
|
||||
"""When no filters are specified, there should be no filtering warnings."""
|
||||
supported = ["kubernetes_deployment", "kubernetes_service"]
|
||||
plugin = FakePlugin(supported_types=supported)
|
||||
profile = make_profile(resource_type_filters=None)
|
||||
scanner = Scanner(profile, plugin)
|
||||
|
||||
result = scanner.scan()
|
||||
|
||||
assert result.warnings == []
|
||||
245
tests/unit/test_snapshot_store.py
Normal file
245
tests/unit/test_snapshot_store.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Unit tests for the SnapshotStore class."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.incremental.snapshot_store import SnapshotStore
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ScanResult,
|
||||
)
|
||||
|
||||
|
||||
def _make_scan_result(
|
||||
profile_hash: str = "abc123",
|
||||
resource_name: str = "test-resource",
|
||||
) -> ScanResult:
|
||||
"""Create a sample ScanResult for testing."""
|
||||
resource = DiscoveredResource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="apps/v1/deployments/default/nginx",
|
||||
name=resource_name,
|
||||
provider=ProviderType.KUBERNETES,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
architecture=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.internal.lab:6443",
|
||||
attributes={"replicas": 3, "image": "nginx:1.25"},
|
||||
raw_references=["default/services/nginx-svc"],
|
||||
)
|
||||
return ScanResult(
|
||||
resources=[resource],
|
||||
warnings=["some warning"],
|
||||
errors=[],
|
||||
scan_timestamp="2024-01-15T10:30:00Z",
|
||||
profile_hash=profile_hash,
|
||||
is_partial=False,
|
||||
)
|
||||
|
||||
|
||||
class TestStoreSnapshot:
|
||||
"""Tests for storing snapshots."""
|
||||
|
||||
def test_store_creates_file_in_correct_directory(self, tmp_path: Path) -> None:
|
||||
"""Storing a snapshot creates a JSON file in the snapshot directory."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
result = _make_scan_result(profile_hash="prof1")
|
||||
|
||||
store.store_snapshot(result, "prof1")
|
||||
|
||||
files = list(snapshot_dir.iterdir())
|
||||
assert len(files) == 1
|
||||
assert files[0].name.startswith("prof1_")
|
||||
assert files[0].name.endswith(".json")
|
||||
|
||||
def test_store_creates_directory_if_not_exists(self, tmp_path: Path) -> None:
|
||||
"""Storing a snapshot creates the snapshot directory if it doesn't exist."""
|
||||
snapshot_dir = tmp_path / "nested" / "deep" / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
result = _make_scan_result()
|
||||
|
||||
store.store_snapshot(result, "abc123")
|
||||
|
||||
assert snapshot_dir.exists()
|
||||
assert len(list(snapshot_dir.iterdir())) == 1
|
||||
|
||||
def test_stored_file_contains_valid_json(self, tmp_path: Path) -> None:
|
||||
"""The stored snapshot file contains valid JSON with expected fields."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
result = _make_scan_result(profile_hash="prof1")
|
||||
|
||||
store.store_snapshot(result, "prof1")
|
||||
|
||||
files = list(snapshot_dir.iterdir())
|
||||
with open(files[0], "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["profile_hash"] == "prof1"
|
||||
assert data["scan_timestamp"] == "2024-01-15T10:30:00Z"
|
||||
assert len(data["resources"]) == 1
|
||||
assert data["resources"][0]["resource_type"] == "kubernetes_deployment"
|
||||
assert data["resources"][0]["provider"] == "kubernetes"
|
||||
assert data["resources"][0]["architecture"] == "aarch64"
|
||||
|
||||
|
||||
class TestLoadPrevious:
|
||||
"""Tests for loading previous snapshots."""
|
||||
|
||||
def test_load_returns_correct_scan_result(self, tmp_path: Path) -> None:
|
||||
"""Loading a previous snapshot returns the correct ScanResult."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
original = _make_scan_result(profile_hash="prof1", resource_name="nginx")
|
||||
|
||||
store.store_snapshot(original, "prof1")
|
||||
loaded = store.load_previous("prof1")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.profile_hash == "prof1"
|
||||
assert loaded.scan_timestamp == "2024-01-15T10:30:00Z"
|
||||
assert loaded.is_partial is False
|
||||
assert loaded.warnings == ["some warning"]
|
||||
assert loaded.errors == []
|
||||
assert len(loaded.resources) == 1
|
||||
|
||||
resource = loaded.resources[0]
|
||||
assert resource.resource_type == "kubernetes_deployment"
|
||||
assert resource.unique_id == "apps/v1/deployments/default/nginx"
|
||||
assert resource.name == "nginx"
|
||||
assert resource.provider == ProviderType.KUBERNETES
|
||||
assert resource.platform_category == PlatformCategory.CONTAINER_ORCHESTRATION
|
||||
assert resource.architecture == CpuArchitecture.AARCH64
|
||||
assert resource.endpoint == "https://k8s-api.internal.lab:6443"
|
||||
assert resource.attributes == {"replicas": 3, "image": "nginx:1.25"}
|
||||
assert resource.raw_references == ["default/services/nginx-svc"]
|
||||
|
||||
def test_load_returns_none_when_no_snapshot_exists(self, tmp_path: Path) -> None:
|
||||
"""Loading when no snapshot exists returns None."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
result = store.load_previous("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_returns_none_when_directory_does_not_exist(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Loading when the snapshot directory doesn't exist returns None."""
|
||||
snapshot_dir = tmp_path / "does_not_exist"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
result = store.load_previous("prof1")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_returns_most_recent_snapshot(self, tmp_path: Path) -> None:
|
||||
"""When multiple snapshots exist, load returns the most recent one."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
# Store first snapshot
|
||||
result1 = _make_scan_result(profile_hash="prof1", resource_name="first")
|
||||
store.store_snapshot(result1, "prof1")
|
||||
time.sleep(1.1) # Ensure different timestamp
|
||||
|
||||
# Store second snapshot
|
||||
result2 = _make_scan_result(profile_hash="prof1", resource_name="second")
|
||||
store.store_snapshot(result2, "prof1")
|
||||
|
||||
loaded = store.load_previous("prof1")
|
||||
assert loaded is not None
|
||||
assert loaded.resources[0].name == "second"
|
||||
|
||||
|
||||
class TestRetention:
|
||||
"""Tests for snapshot retention/pruning."""
|
||||
|
||||
def test_retains_at_least_two_most_recent_snapshots(self, tmp_path: Path) -> None:
|
||||
"""Only the 2 most recent snapshots are kept per profile."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
# Store 4 snapshots with different timestamps
|
||||
for i in range(4):
|
||||
result = _make_scan_result(
|
||||
profile_hash="prof1", resource_name=f"resource-{i}"
|
||||
)
|
||||
store.store_snapshot(result, "prof1")
|
||||
time.sleep(1.1) # Ensure different timestamps
|
||||
|
||||
# Should only have 2 files remaining
|
||||
files = list(snapshot_dir.iterdir())
|
||||
assert len(files) == 2
|
||||
|
||||
# The most recent should be loadable
|
||||
loaded = store.load_previous("prof1")
|
||||
assert loaded is not None
|
||||
assert loaded.resources[0].name == "resource-3"
|
||||
|
||||
def test_two_snapshots_are_not_pruned(self, tmp_path: Path) -> None:
|
||||
"""Exactly 2 snapshots are retained without pruning."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
store.store_snapshot(_make_scan_result(profile_hash="prof1"), "prof1")
|
||||
time.sleep(1.1)
|
||||
store.store_snapshot(_make_scan_result(profile_hash="prof1"), "prof1")
|
||||
|
||||
files = list(snapshot_dir.iterdir())
|
||||
assert len(files) == 2
|
||||
|
||||
|
||||
class TestMultipleProfiles:
|
||||
"""Tests for multiple profile isolation."""
|
||||
|
||||
def test_multiple_profiles_do_not_interfere(self, tmp_path: Path) -> None:
|
||||
"""Snapshots from different profiles don't interfere with each other."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
result_a = _make_scan_result(profile_hash="profile_a", resource_name="res-a")
|
||||
result_b = _make_scan_result(profile_hash="profile_b", resource_name="res-b")
|
||||
|
||||
store.store_snapshot(result_a, "profile_a")
|
||||
store.store_snapshot(result_b, "profile_b")
|
||||
|
||||
loaded_a = store.load_previous("profile_a")
|
||||
loaded_b = store.load_previous("profile_b")
|
||||
|
||||
assert loaded_a is not None
|
||||
assert loaded_a.resources[0].name == "res-a"
|
||||
assert loaded_b is not None
|
||||
assert loaded_b.resources[0].name == "res-b"
|
||||
|
||||
def test_pruning_only_affects_matching_profile(self, tmp_path: Path) -> None:
|
||||
"""Pruning for one profile does not remove snapshots from another."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
store = SnapshotStore(base_dir=str(snapshot_dir))
|
||||
|
||||
# Store 4 snapshots for profile_a (should prune to 2)
|
||||
for i in range(4):
|
||||
result = _make_scan_result(
|
||||
profile_hash="profile_a", resource_name=f"a-{i}"
|
||||
)
|
||||
store.store_snapshot(result, "profile_a")
|
||||
time.sleep(1.1)
|
||||
|
||||
# Store 1 snapshot for profile_b
|
||||
result_b = _make_scan_result(profile_hash="profile_b", resource_name="b-0")
|
||||
store.store_snapshot(result_b, "profile_b")
|
||||
|
||||
# profile_a should have 2 files, profile_b should have 1
|
||||
all_files = list(snapshot_dir.iterdir())
|
||||
a_files = [f for f in all_files if f.name.startswith("profile_a_")]
|
||||
b_files = [f for f in all_files if f.name.startswith("profile_b_")]
|
||||
|
||||
assert len(a_files) == 2
|
||||
assert len(b_files) == 1
|
||||
681
tests/unit/test_state_builder.py
Normal file
681
tests/unit/test_state_builder.py
Normal file
@@ -0,0 +1,681 @@
|
||||
"""Unit tests for the StateBuilder."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CodeGenerationResult,
|
||||
CpuArchitecture,
|
||||
DependencyGraph,
|
||||
DiscoveredResource,
|
||||
GeneratedFile,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ResourceRelationship,
|
||||
StateEntry,
|
||||
StateFile,
|
||||
)
|
||||
from iac_reverse.state_builder import StateBuilder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "apps/v1/deployments/default/nginx",
|
||||
name: str = "nginx",
|
||||
raw_references: list[str] | None = None,
|
||||
attributes: dict | None = None,
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
) -> 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=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes=attributes or {"replicas": 3, "image": "nginx:1.25"},
|
||||
raw_references=raw_references or [],
|
||||
)
|
||||
|
||||
|
||||
def make_code_generation_result() -> CodeGenerationResult:
|
||||
"""Create a minimal CodeGenerationResult for testing."""
|
||||
return CodeGenerationResult(
|
||||
resource_files=[
|
||||
GeneratedFile(
|
||||
filename="kubernetes_deployment.tf",
|
||||
content='resource "kubernetes_deployment" "nginx" {}',
|
||||
resource_count=1,
|
||||
)
|
||||
],
|
||||
variables_file=GeneratedFile(
|
||||
filename="variables.tf", content="", resource_count=0
|
||||
),
|
||||
provider_file=GeneratedFile(
|
||||
filename="providers.tf", content="", resource_count=0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_dependency_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=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Single resource produces valid state entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleResource:
|
||||
"""Tests for state generation with a single resource."""
|
||||
|
||||
def test_single_resource_produces_one_state_entry(self):
|
||||
"""A single resource in the graph produces exactly one state entry."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="3.2.1")
|
||||
|
||||
assert len(state.resources) == 1
|
||||
|
||||
def test_state_entry_has_correct_resource_type(self):
|
||||
"""State entry resource_type matches the discovered resource type."""
|
||||
resource = make_resource(resource_type="kubernetes_deployment")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.resources[0].resource_type == "kubernetes_deployment"
|
||||
|
||||
def test_state_entry_has_sanitized_resource_name(self):
|
||||
"""State entry resource_name is a valid Terraform identifier."""
|
||||
resource = make_resource(name="my-nginx-app")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
# Hyphens should be replaced with underscores
|
||||
assert state.resources[0].resource_name == "my_nginx_app"
|
||||
|
||||
def test_state_entry_provider_id_is_unique_id(self):
|
||||
"""State entry provider_id is the live infrastructure unique_id."""
|
||||
resource = make_resource(unique_id="apps/v1/deployments/default/nginx")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.resources[0].provider_id == "apps/v1/deployments/default/nginx"
|
||||
|
||||
def test_state_entry_attributes_from_discovery(self):
|
||||
"""State entry attributes contain the full discovery attribute set."""
|
||||
attrs = {"replicas": 3, "image": "nginx:1.25", "namespace": "default"}
|
||||
resource = make_resource(attributes=attrs)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.resources[0].attributes == attrs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Multiple resources produce multiple state entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleResources:
|
||||
"""Tests for state generation with multiple resources."""
|
||||
|
||||
def test_multiple_resources_produce_multiple_entries(self):
|
||||
"""Each resource in the graph produces a corresponding state entry."""
|
||||
resources = [
|
||||
make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc/nginx-svc",
|
||||
name="nginx-svc",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
),
|
||||
]
|
||||
graph = make_dependency_graph(resources)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="2.0.0")
|
||||
|
||||
assert len(state.resources) == 3
|
||||
|
||||
def test_multiple_resources_have_distinct_entries(self):
|
||||
"""Each state entry corresponds to a different resource."""
|
||||
resources = [
|
||||
make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc/redis",
|
||||
name="redis",
|
||||
),
|
||||
]
|
||||
graph = make_dependency_graph(resources)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
types = {e.resource_type for e in state.resources}
|
||||
assert "kubernetes_deployment" in types
|
||||
assert "kubernetes_service" in types
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Lineage is a valid UUID
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLineage:
|
||||
"""Tests for state file lineage UUID generation."""
|
||||
|
||||
def test_lineage_is_valid_uuid(self):
|
||||
"""The state file lineage is a valid UUID string."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
# Should not raise ValueError
|
||||
parsed = uuid.UUID(state.lineage)
|
||||
assert str(parsed) == state.lineage
|
||||
|
||||
def test_lineage_is_unique_per_build(self):
|
||||
"""Each build produces a different lineage UUID."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state1 = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
state2 = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state1.lineage != state2.lineage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Dependencies are included as Terraform resource addresses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDependencies:
|
||||
"""Tests for dependency references in state entries."""
|
||||
|
||||
def test_dependencies_as_terraform_addresses(self):
|
||||
"""Dependencies are formatted as resource_type.resource_name."""
|
||||
namespace = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
raw_references=["ns/default"],
|
||||
)
|
||||
relationship = ResourceRelationship(
|
||||
source_id="deploy/nginx",
|
||||
target_id="ns/default",
|
||||
relationship_type="dependency",
|
||||
source_attribute="namespace",
|
||||
)
|
||||
graph = make_dependency_graph(
|
||||
[namespace, deployment], relationships=[relationship]
|
||||
)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
# Find the deployment entry
|
||||
deploy_entry = next(
|
||||
e for e in state.resources if e.resource_type == "kubernetes_deployment"
|
||||
)
|
||||
assert "kubernetes_namespace.default" in deploy_entry.dependencies
|
||||
|
||||
def test_resource_without_dependencies_has_empty_list(self):
|
||||
"""A resource with no relationships has an empty dependencies list."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.resources[0].dependencies == []
|
||||
|
||||
def test_multiple_dependencies_all_included(self):
|
||||
"""All dependency relationships are included in the state entry."""
|
||||
ns = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
svc = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc/nginx-svc",
|
||||
name="nginx-svc",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
relationships = [
|
||||
ResourceRelationship(
|
||||
source_id="deploy/nginx",
|
||||
target_id="ns/default",
|
||||
relationship_type="dependency",
|
||||
source_attribute="namespace",
|
||||
),
|
||||
ResourceRelationship(
|
||||
source_id="deploy/nginx",
|
||||
target_id="svc/nginx-svc",
|
||||
relationship_type="reference",
|
||||
source_attribute="service",
|
||||
),
|
||||
]
|
||||
graph = make_dependency_graph([ns, svc, deployment], relationships=relationships)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
deploy_entry = next(
|
||||
e for e in state.resources if e.resource_type == "kubernetes_deployment"
|
||||
)
|
||||
assert len(deploy_entry.dependencies) == 2
|
||||
assert "kubernetes_namespace.default" in deploy_entry.dependencies
|
||||
assert "kubernetes_service.nginx_svc" in deploy_entry.dependencies
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Sensitive attributes are marked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSensitiveAttributes:
|
||||
"""Tests for sensitive attribute detection."""
|
||||
|
||||
def test_password_attribute_marked_sensitive(self):
|
||||
"""Attributes containing 'password' are marked sensitive."""
|
||||
resource = make_resource(
|
||||
attributes={"db_password": "secret123", "name": "mydb"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "db_password" in state.resources[0].sensitive_attributes
|
||||
|
||||
def test_token_attribute_marked_sensitive(self):
|
||||
"""Attributes containing 'token' are marked sensitive."""
|
||||
resource = make_resource(
|
||||
attributes={"api_token": "abc123", "endpoint": "https://api.local"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "api_token" in state.resources[0].sensitive_attributes
|
||||
|
||||
def test_secret_attribute_marked_sensitive(self):
|
||||
"""Attributes containing 'secret' are marked sensitive."""
|
||||
resource = make_resource(
|
||||
attributes={"client_secret": "xyz", "client_id": "app1"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "client_secret" in state.resources[0].sensitive_attributes
|
||||
|
||||
def test_key_attribute_marked_sensitive(self):
|
||||
"""Attributes containing 'key' are marked sensitive."""
|
||||
resource = make_resource(
|
||||
attributes={"private_key": "-----BEGIN RSA KEY-----", "name": "cert1"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "private_key" in state.resources[0].sensitive_attributes
|
||||
|
||||
def test_certificate_attribute_marked_sensitive(self):
|
||||
"""Attributes containing 'certificate' are marked sensitive."""
|
||||
resource = make_resource(
|
||||
attributes={"tls_certificate": "cert-data", "port": 443}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "tls_certificate" in state.resources[0].sensitive_attributes
|
||||
|
||||
def test_non_sensitive_attributes_not_marked(self):
|
||||
"""Attributes without sensitive patterns are not marked."""
|
||||
resource = make_resource(
|
||||
attributes={"replicas": 3, "image": "nginx:1.25", "namespace": "default"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.resources[0].sensitive_attributes == []
|
||||
|
||||
def test_nested_sensitive_attributes_detected(self):
|
||||
"""Sensitive attributes in nested dicts are detected."""
|
||||
resource = make_resource(
|
||||
attributes={
|
||||
"config": {"database_password": "secret", "host": "localhost"}
|
||||
}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert "config.database_password" in state.resources[0].sensitive_attributes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Schema version is set from provider_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemaVersion:
|
||||
"""Tests for schema_version setting from provider_version."""
|
||||
|
||||
def test_schema_version_from_major_version(self):
|
||||
"""Schema version is the major version number from provider_version."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="3.2.1")
|
||||
|
||||
assert state.resources[0].schema_version == 3
|
||||
|
||||
def test_schema_version_single_digit(self):
|
||||
"""Schema version works with a single digit version string."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1")
|
||||
|
||||
assert state.resources[0].schema_version == 1
|
||||
|
||||
def test_schema_version_defaults_to_zero_on_invalid(self):
|
||||
"""Schema version defaults to 0 if provider_version is unparseable."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="invalid")
|
||||
|
||||
assert state.resources[0].schema_version == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: to_json() produces valid JSON with correct structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToJson:
|
||||
"""Tests for state file JSON serialization."""
|
||||
|
||||
def test_to_json_produces_valid_json(self):
|
||||
"""to_json() output is valid JSON."""
|
||||
resource = make_resource(
|
||||
attributes={"replicas": 3, "image": "nginx:1.25"}
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
json_str = state.to_json()
|
||||
parsed = json.loads(json_str)
|
||||
assert isinstance(parsed, dict)
|
||||
|
||||
def test_to_json_has_version_4(self):
|
||||
"""JSON output has version field set to 4."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
assert parsed["version"] == 4
|
||||
|
||||
def test_to_json_has_serial_1(self):
|
||||
"""JSON output has serial field set to 1."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
assert parsed["serial"] == 1
|
||||
|
||||
def test_to_json_has_terraform_version(self):
|
||||
"""JSON output includes the terraform_version."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder(terraform_version="1.7.0")
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
assert parsed["terraform_version"] == "1.7.0"
|
||||
|
||||
def test_to_json_has_valid_lineage_uuid(self):
|
||||
"""JSON output lineage is a valid UUID."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
# Should not raise ValueError
|
||||
uuid.UUID(parsed["lineage"])
|
||||
|
||||
def test_to_json_resources_have_correct_structure(self):
|
||||
"""JSON resources have mode, type, name, provider, and instances."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
name="nginx",
|
||||
attributes={"replicas": 3},
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="2.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
assert len(parsed["resources"]) == 1
|
||||
|
||||
res = parsed["resources"][0]
|
||||
assert res["mode"] == "managed"
|
||||
assert res["type"] == "kubernetes_deployment"
|
||||
assert res["name"] == "nginx"
|
||||
assert "provider" in res
|
||||
assert len(res["instances"]) == 1
|
||||
|
||||
def test_to_json_instance_has_attributes_with_id(self):
|
||||
"""JSON instance attributes include the provider_id as 'id'."""
|
||||
resource = make_resource(
|
||||
unique_id="apps/v1/deployments/default/nginx",
|
||||
attributes={"replicas": 3},
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
instance = parsed["resources"][0]["instances"][0]
|
||||
assert instance["attributes"]["id"] == "apps/v1/deployments/default/nginx"
|
||||
assert instance["attributes"]["replicas"] == 3
|
||||
|
||||
def test_to_json_instance_has_schema_version(self):
|
||||
"""JSON instance includes schema_version."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="3.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
instance = parsed["resources"][0]["instances"][0]
|
||||
assert instance["schema_version"] == 3
|
||||
|
||||
def test_to_json_instance_has_dependencies(self):
|
||||
"""JSON instance includes dependencies list."""
|
||||
ns = make_resource(
|
||||
resource_type="kubernetes_namespace",
|
||||
unique_id="ns/default",
|
||||
name="default",
|
||||
)
|
||||
deployment = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
relationship = ResourceRelationship(
|
||||
source_id="deploy/nginx",
|
||||
target_id="ns/default",
|
||||
relationship_type="dependency",
|
||||
source_attribute="namespace",
|
||||
)
|
||||
graph = make_dependency_graph([ns, deployment], relationships=[relationship])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
parsed = json.loads(state.to_json())
|
||||
deploy_res = next(
|
||||
r for r in parsed["resources"] if r["type"] == "kubernetes_deployment"
|
||||
)
|
||||
instance = deploy_res["instances"][0]
|
||||
assert "kubernetes_namespace.default" in instance["dependencies"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: State file metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStateFileMetadata:
|
||||
"""Tests for state file top-level metadata."""
|
||||
|
||||
def test_version_is_4(self):
|
||||
"""State file version is always 4."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.version == 4
|
||||
|
||||
def test_serial_is_1(self):
|
||||
"""State file serial is 1 for initial generation."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.serial == 1
|
||||
|
||||
def test_custom_terraform_version(self):
|
||||
"""StateBuilder accepts a custom terraform_version."""
|
||||
resource = make_resource()
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder(terraform_version="1.8.0")
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert state.terraform_version == "1.8.0"
|
||||
468
tests/unit/test_state_builder_unmapped.py
Normal file
468
tests/unit/test_state_builder_unmapped.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""Unit tests for unmapped resource handling in StateBuilder.
|
||||
|
||||
Tests that resources with missing provider-assigned identifiers or
|
||||
unrecognized resource types are excluded from the state file, warnings
|
||||
are logged, and the unmapped resources list is correctly populated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CodeGenerationResult,
|
||||
CpuArchitecture,
|
||||
DependencyGraph,
|
||||
DiscoveredResource,
|
||||
GeneratedFile,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
ResourceRelationship,
|
||||
)
|
||||
from iac_reverse.state_builder import StateBuilder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_resource(
|
||||
resource_type: str = "kubernetes_deployment",
|
||||
unique_id: str = "apps/v1/deployments/default/nginx",
|
||||
name: str = "nginx",
|
||||
attributes: dict | None = None,
|
||||
provider: ProviderType = ProviderType.KUBERNETES,
|
||||
platform_category: PlatformCategory = PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
) -> 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=CpuArchitecture.AARCH64,
|
||||
endpoint="https://k8s-api.local:6443",
|
||||
attributes=attributes or {"replicas": 3, "image": "nginx:1.25"},
|
||||
raw_references=[],
|
||||
)
|
||||
|
||||
|
||||
def make_code_generation_result() -> CodeGenerationResult:
|
||||
"""Create a minimal CodeGenerationResult for testing."""
|
||||
return CodeGenerationResult(
|
||||
resource_files=[
|
||||
GeneratedFile(
|
||||
filename="kubernetes_deployment.tf",
|
||||
content='resource "kubernetes_deployment" "nginx" {}',
|
||||
resource_count=1,
|
||||
)
|
||||
],
|
||||
variables_file=GeneratedFile(
|
||||
filename="variables.tf", content="", resource_count=0
|
||||
),
|
||||
provider_file=GeneratedFile(
|
||||
filename="providers.tf", content="", resource_count=0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_dependency_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=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Resource with empty unique_id is excluded from state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyUniqueIdExcluded:
|
||||
"""Resources with empty unique_id are excluded from state."""
|
||||
|
||||
def test_empty_string_unique_id_excluded(self):
|
||||
"""A resource with empty string unique_id produces no state entry."""
|
||||
resource = make_resource(unique_id="")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
def test_whitespace_only_unique_id_excluded(self):
|
||||
"""A resource with whitespace-only unique_id produces no state entry."""
|
||||
resource = make_resource(unique_id=" ")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
def test_tabs_and_newlines_unique_id_excluded(self):
|
||||
"""A resource with tabs/newlines-only unique_id is excluded."""
|
||||
resource = make_resource(unique_id="\t\n")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Resource with None-like identifier is excluded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoneLikeIdentifierExcluded:
|
||||
"""Resources with None-like identifiers are excluded from state."""
|
||||
|
||||
def test_none_unique_id_excluded(self):
|
||||
"""A resource with None unique_id produces no state entry."""
|
||||
resource = make_resource()
|
||||
# Manually set unique_id to None (bypassing type hints)
|
||||
resource.unique_id = None # type: ignore[assignment]
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
def test_empty_string_is_falsy_excluded(self):
|
||||
"""Empty string is falsy and should be excluded."""
|
||||
resource = make_resource(unique_id="")
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Unrecognized resource type is excluded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnrecognizedResourceTypeExcluded:
|
||||
"""Resources with unrecognized resource types are excluded."""
|
||||
|
||||
def test_unknown_resource_type_excluded(self):
|
||||
"""A resource with an unrecognized type produces no state entry."""
|
||||
resource = make_resource(
|
||||
resource_type="totally_unknown_type",
|
||||
unique_id="some/valid/id",
|
||||
name="mystery",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
def test_misspelled_resource_type_excluded(self):
|
||||
"""A misspelled resource type is excluded."""
|
||||
resource = make_resource(
|
||||
resource_type="kuberntes_deployment", # typo
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Warning is logged for unmapped resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWarningLogged:
|
||||
"""Warnings are logged for unmapped resources."""
|
||||
|
||||
def test_warning_logged_for_empty_unique_id(self, caplog):
|
||||
"""A warning is logged when a resource has empty unique_id."""
|
||||
resource = make_resource(
|
||||
unique_id="",
|
||||
name="orphan-resource",
|
||||
resource_type="kubernetes_deployment",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert any("orphan-resource" in record.message for record in caplog.records)
|
||||
assert any(
|
||||
"missing provider-assigned resource identifier" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
def test_warning_logged_for_unrecognized_type(self, caplog):
|
||||
"""A warning is logged when a resource has unrecognized type."""
|
||||
resource = make_resource(
|
||||
resource_type="alien_resource",
|
||||
unique_id="some/id",
|
||||
name="alien",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert any("alien" in record.message for record in caplog.records)
|
||||
assert any(
|
||||
"not recognized" in record.message for record in caplog.records
|
||||
)
|
||||
|
||||
def test_warning_includes_resource_type_and_name(self, caplog):
|
||||
"""Warning message identifies the resource by type and name."""
|
||||
resource = make_resource(
|
||||
resource_type="unknown_type",
|
||||
unique_id="id/123",
|
||||
name="my-resource",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
# The warning should contain the resource identifier (type.name)
|
||||
assert any(
|
||||
"unknown_type.my-resource" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Mapped resources still produce valid state entries alongside unmapped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMappedAlongsideUnmapped:
|
||||
"""Mapped resources produce valid entries even when unmapped ones exist."""
|
||||
|
||||
def test_valid_resource_produces_entry_alongside_unmapped(self):
|
||||
"""A valid resource still gets a state entry when others are unmapped."""
|
||||
valid_resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
unmapped_resource = make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="", # empty - unmappable
|
||||
name="orphan-svc",
|
||||
)
|
||||
graph = make_dependency_graph([valid_resource, unmapped_resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 1
|
||||
assert state.resources[0].resource_type == "kubernetes_deployment"
|
||||
assert state.resources[0].provider_id == "deploy/nginx"
|
||||
|
||||
def test_multiple_valid_resources_with_one_unmapped(self):
|
||||
"""Multiple valid resources produce entries; unmapped one is excluded."""
|
||||
resources = [
|
||||
make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc/redis",
|
||||
name="redis",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="unknown_type",
|
||||
unique_id="id/mystery",
|
||||
name="mystery",
|
||||
),
|
||||
]
|
||||
graph = make_dependency_graph(resources)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(state.resources) == 2
|
||||
types = {e.resource_type for e in state.resources}
|
||||
assert "kubernetes_deployment" in types
|
||||
assert "kubernetes_service" in types
|
||||
assert "unknown_type" not in types
|
||||
|
||||
def test_valid_entries_have_correct_attributes(self):
|
||||
"""Valid entries retain full attributes even when unmapped exist."""
|
||||
valid_resource = make_resource(
|
||||
resource_type="docker_service",
|
||||
unique_id="svc/web",
|
||||
name="web",
|
||||
attributes={"replicas": 2, "image": "web:latest"},
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
)
|
||||
unmapped_resource = make_resource(
|
||||
resource_type="docker_service",
|
||||
unique_id="",
|
||||
name="broken",
|
||||
provider=ProviderType.DOCKER_SWARM,
|
||||
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
|
||||
)
|
||||
graph = make_dependency_graph([valid_resource, unmapped_resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
state = builder.build(code_result, graph, provider_version="2.0.0")
|
||||
|
||||
assert len(state.resources) == 1
|
||||
assert state.resources[0].attributes == {"replicas": 2, "image": "web:latest"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Unmapped resources list contains correct entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnmappedResourcesList:
|
||||
"""The unmapped_resources property contains correct entries."""
|
||||
|
||||
def test_unmapped_list_empty_when_all_mapped(self):
|
||||
"""When all resources are mappable, unmapped list is empty."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert builder.unmapped_resources == []
|
||||
|
||||
def test_unmapped_list_contains_empty_id_resource(self):
|
||||
"""Resource with empty unique_id appears in unmapped list."""
|
||||
resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="",
|
||||
name="orphan",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(builder.unmapped_resources) == 1
|
||||
identifier, reason = builder.unmapped_resources[0]
|
||||
assert "kubernetes_deployment.orphan" == identifier
|
||||
assert "missing provider-assigned resource identifier" in reason
|
||||
|
||||
def test_unmapped_list_contains_unrecognized_type_resource(self):
|
||||
"""Resource with unrecognized type appears in unmapped list."""
|
||||
resource = make_resource(
|
||||
resource_type="alien_widget",
|
||||
unique_id="widget/123",
|
||||
name="my-widget",
|
||||
)
|
||||
graph = make_dependency_graph([resource])
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(builder.unmapped_resources) == 1
|
||||
identifier, reason = builder.unmapped_resources[0]
|
||||
assert "alien_widget.my-widget" == identifier
|
||||
assert "not recognized" in reason
|
||||
|
||||
def test_unmapped_list_contains_multiple_entries(self):
|
||||
"""Multiple unmapped resources all appear in the list."""
|
||||
resources = [
|
||||
make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="",
|
||||
name="no-id",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="fake_type",
|
||||
unique_id="fake/id",
|
||||
name="fake",
|
||||
),
|
||||
make_resource(
|
||||
resource_type="kubernetes_service",
|
||||
unique_id="svc/valid",
|
||||
name="valid",
|
||||
),
|
||||
]
|
||||
graph = make_dependency_graph(resources)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
builder.build(code_result, graph, provider_version="1.0.0")
|
||||
|
||||
assert len(builder.unmapped_resources) == 2
|
||||
identifiers = [entry[0] for entry in builder.unmapped_resources]
|
||||
assert "kubernetes_deployment.no-id" in identifiers
|
||||
assert "fake_type.fake" in identifiers
|
||||
|
||||
def test_unmapped_list_resets_on_new_build(self):
|
||||
"""The unmapped list is reset on each new build call."""
|
||||
unmapped_resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="",
|
||||
name="orphan",
|
||||
)
|
||||
valid_resource = make_resource(
|
||||
resource_type="kubernetes_deployment",
|
||||
unique_id="deploy/nginx",
|
||||
name="nginx",
|
||||
)
|
||||
code_result = make_code_generation_result()
|
||||
|
||||
builder = StateBuilder()
|
||||
|
||||
# First build with unmapped resource
|
||||
graph1 = make_dependency_graph([unmapped_resource])
|
||||
builder.build(code_result, graph1, provider_version="1.0.0")
|
||||
assert len(builder.unmapped_resources) == 1
|
||||
|
||||
# Second build with valid resource - unmapped list should reset
|
||||
graph2 = make_dependency_graph([valid_resource])
|
||||
builder.build(code_result, graph2, provider_version="1.0.0")
|
||||
assert len(builder.unmapped_resources) == 0
|
||||
499
tests/unit/test_synology_plugin.py
Normal file
499
tests/unit/test_synology_plugin.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""Unit tests for the Synology DSM provider plugin."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
PlatformCategory,
|
||||
ScanProgress,
|
||||
)
|
||||
from iac_reverse.scanner import AuthenticationError, SynologyPlugin
|
||||
|
||||
|
||||
class TestSynologyPluginAuthentication:
|
||||
"""Tests for SynologyPlugin.authenticate()."""
|
||||
|
||||
def test_authenticate_missing_host_raises(self):
|
||||
"""Authentication fails when host is not provided."""
|
||||
plugin = SynologyPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"port": "5001", "username": "admin", "password": "pass"})
|
||||
assert "synology" in str(exc_info.value).lower()
|
||||
assert "host" in str(exc_info.value).lower()
|
||||
|
||||
def test_authenticate_missing_username_raises(self):
|
||||
"""Authentication fails when username is not provided."""
|
||||
plugin = SynologyPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"host": "nas01", "port": "5001", "password": "pass"})
|
||||
assert "username" in str(exc_info.value).lower()
|
||||
|
||||
def test_authenticate_missing_password_raises(self):
|
||||
"""Authentication fails when password is not provided."""
|
||||
plugin = SynologyPlugin()
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({"host": "nas01", "port": "5001", "username": "admin"})
|
||||
assert "password" in str(exc_info.value).lower()
|
||||
|
||||
@patch("iac_reverse.scanner.synology_plugin.SynologyDSM")
|
||||
def test_authenticate_success(self, mock_dsm_class):
|
||||
"""Successful authentication sets internal state."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.login.return_value = True
|
||||
mock_dsm_class.return_value = mock_api
|
||||
|
||||
plugin = SynologyPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "nas01.local",
|
||||
"port": "5001",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"use_ssl": "true",
|
||||
})
|
||||
|
||||
assert plugin._authenticated is True
|
||||
mock_dsm_class.assert_called_once_with(
|
||||
"nas01.local", 5001, "admin", "secret", use_https=True, verify_ssl=False
|
||||
)
|
||||
|
||||
@patch("iac_reverse.scanner.synology_plugin.SynologyDSM")
|
||||
def test_authenticate_login_failure(self, mock_dsm_class):
|
||||
"""Authentication raises when login returns False."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.login.return_value = False
|
||||
mock_dsm_class.return_value = mock_api
|
||||
|
||||
plugin = SynologyPlugin()
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({
|
||||
"host": "nas01.local",
|
||||
"port": "5001",
|
||||
"username": "admin",
|
||||
"password": "wrong",
|
||||
})
|
||||
assert "synology" in str(exc_info.value).lower()
|
||||
assert "login failed" in str(exc_info.value).lower()
|
||||
|
||||
@patch("iac_reverse.scanner.synology_plugin.SynologyDSM")
|
||||
def test_authenticate_connection_error(self, mock_dsm_class):
|
||||
"""Authentication raises on connection error."""
|
||||
mock_dsm_class.side_effect = ConnectionError("Connection refused")
|
||||
|
||||
plugin = SynologyPlugin()
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
plugin.authenticate({
|
||||
"host": "nas01.local",
|
||||
"port": "5001",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
})
|
||||
assert "synology" in str(exc_info.value).lower()
|
||||
|
||||
@patch("iac_reverse.scanner.synology_plugin.SynologyDSM")
|
||||
def test_authenticate_use_ssl_false(self, mock_dsm_class):
|
||||
"""Authentication respects use_ssl=false."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.login.return_value = True
|
||||
mock_dsm_class.return_value = mock_api
|
||||
|
||||
plugin = SynologyPlugin()
|
||||
plugin.authenticate({
|
||||
"host": "nas01.local",
|
||||
"port": "5000",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"use_ssl": "false",
|
||||
})
|
||||
|
||||
mock_dsm_class.assert_called_once_with(
|
||||
"nas01.local", 5000, "admin", "secret", use_https=False, verify_ssl=False
|
||||
)
|
||||
|
||||
|
||||
class TestSynologyPluginMetadata:
|
||||
"""Tests for SynologyPlugin metadata methods."""
|
||||
|
||||
def test_get_platform_category(self):
|
||||
"""Returns STORAGE_APPLIANCE category."""
|
||||
plugin = SynologyPlugin()
|
||||
assert plugin.get_platform_category() == PlatformCategory.STORAGE_APPLIANCE
|
||||
|
||||
def test_list_supported_resource_types(self):
|
||||
"""Returns all five Synology resource types."""
|
||||
plugin = SynologyPlugin()
|
||||
types = plugin.list_supported_resource_types()
|
||||
assert types == [
|
||||
"synology_shared_folder",
|
||||
"synology_volume",
|
||||
"synology_storage_pool",
|
||||
"synology_replication_task",
|
||||
"synology_user",
|
||||
]
|
||||
|
||||
def test_list_endpoints_default(self):
|
||||
"""Returns HTTPS endpoint by default."""
|
||||
plugin = SynologyPlugin()
|
||||
plugin._host = "nas01.local"
|
||||
plugin._port = "5001"
|
||||
plugin._use_ssl = True
|
||||
assert plugin.list_endpoints() == ["https://nas01.local:5001"]
|
||||
|
||||
def test_list_endpoints_no_ssl(self):
|
||||
"""Returns HTTP endpoint when SSL is disabled."""
|
||||
plugin = SynologyPlugin()
|
||||
plugin._host = "nas01.local"
|
||||
plugin._port = "5000"
|
||||
plugin._use_ssl = False
|
||||
assert plugin.list_endpoints() == ["http://nas01.local:5000"]
|
||||
|
||||
|
||||
class TestSynologyPluginArchitecture:
|
||||
"""Tests for SynologyPlugin.detect_architecture()."""
|
||||
|
||||
def test_detect_architecture_no_api(self):
|
||||
"""Returns AMD64 when no API is connected."""
|
||||
plugin = SynologyPlugin()
|
||||
assert plugin.detect_architecture("https://nas01:5001") == CpuArchitecture.AMD64
|
||||
|
||||
def test_detect_architecture_arm(self):
|
||||
"""Detects ARM architecture from model info."""
|
||||
plugin = SynologyPlugin()
|
||||
mock_info = MagicMock()
|
||||
mock_info.model = "DS220j"
|
||||
mock_info.cpu_hardware_name = "ARM Realtek RTD1296"
|
||||
plugin._api = MagicMock()
|
||||
plugin._api.information = mock_info
|
||||
|
||||
result = plugin.detect_architecture("https://nas01:5001")
|
||||
assert result == CpuArchitecture.ARM
|
||||
|
||||
def test_detect_architecture_aarch64(self):
|
||||
"""Detects AArch64 architecture from model info."""
|
||||
plugin = SynologyPlugin()
|
||||
mock_info = MagicMock()
|
||||
mock_info.model = "DS923+"
|
||||
mock_info.cpu_hardware_name = "aarch64 Cortex-A55"
|
||||
plugin._api = MagicMock()
|
||||
plugin._api.information = mock_info
|
||||
|
||||
result = plugin.detect_architecture("https://nas01:5001")
|
||||
assert result == CpuArchitecture.AARCH64
|
||||
|
||||
def test_detect_architecture_amd64(self):
|
||||
"""Detects AMD64 architecture from model info."""
|
||||
plugin = SynologyPlugin()
|
||||
mock_info = MagicMock()
|
||||
mock_info.model = "DS1621+"
|
||||
mock_info.cpu_hardware_name = "AMD Ryzen V1500B"
|
||||
plugin._api = MagicMock()
|
||||
plugin._api.information = mock_info
|
||||
|
||||
result = plugin.detect_architecture("https://nas01:5001")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
def test_detect_architecture_alpine_is_arm(self):
|
||||
"""Detects Alpine (Marvell ARM) as ARM architecture."""
|
||||
plugin = SynologyPlugin()
|
||||
mock_info = MagicMock()
|
||||
mock_info.model = "DS218j"
|
||||
mock_info.cpu_hardware_name = "Alpine AL-212"
|
||||
plugin._api = MagicMock()
|
||||
plugin._api.information = mock_info
|
||||
|
||||
result = plugin.detect_architecture("https://nas01:5001")
|
||||
assert result == CpuArchitecture.ARM
|
||||
|
||||
def test_detect_architecture_exception_returns_amd64(self):
|
||||
"""Returns AMD64 on exception during detection."""
|
||||
plugin = SynologyPlugin()
|
||||
plugin._api = MagicMock()
|
||||
plugin._api.information = property(lambda self: (_ for _ in ()).throw(RuntimeError("fail")))
|
||||
# Simulate attribute access raising
|
||||
type(plugin._api).information = property(lambda self: (_ for _ in ()).throw(RuntimeError("fail")))
|
||||
|
||||
result = plugin.detect_architecture("https://nas01:5001")
|
||||
assert result == CpuArchitecture.AMD64
|
||||
|
||||
|
||||
class TestSynologyPluginDiscovery:
|
||||
"""Tests for SynologyPlugin.discover_resources()."""
|
||||
|
||||
def _make_authenticated_plugin(self):
|
||||
"""Create a plugin with mocked API."""
|
||||
plugin = SynologyPlugin()
|
||||
plugin._api = MagicMock()
|
||||
plugin._authenticated = True
|
||||
plugin._host = "nas01.local"
|
||||
plugin._port = "5001"
|
||||
plugin._use_ssl = True
|
||||
|
||||
# Default: no architecture info
|
||||
mock_info = MagicMock()
|
||||
mock_info.model = "DS920+"
|
||||
mock_info.cpu_hardware_name = "Intel Celeron J4125"
|
||||
plugin._api.information = mock_info
|
||||
|
||||
return plugin
|
||||
|
||||
def test_discover_shared_folders(self):
|
||||
"""Discovers shared folders from storage API."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.shares = [
|
||||
{
|
||||
"name": "photos",
|
||||
"path": "/volume1/photos",
|
||||
"desc": "Photo library",
|
||||
"is_encrypted": False,
|
||||
"enable_recycle_bin": True,
|
||||
"vol_path": "/volume1",
|
||||
},
|
||||
{
|
||||
"name": "backups",
|
||||
"path": "/volume1/backups",
|
||||
"desc": "Backup storage",
|
||||
"is_encrypted": True,
|
||||
"enable_recycle_bin": False,
|
||||
"vol_path": "/volume1",
|
||||
},
|
||||
]
|
||||
|
||||
progress_updates = []
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_shared_folder"],
|
||||
progress_callback=lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].resource_type == "synology_shared_folder"
|
||||
assert result.resources[0].name == "photos"
|
||||
assert result.resources[0].unique_id == "synology/shared_folder/photos"
|
||||
assert result.resources[0].provider.value == "synology"
|
||||
assert result.resources[0].platform_category == PlatformCategory.STORAGE_APPLIANCE
|
||||
assert result.resources[0].architecture == CpuArchitecture.AMD64
|
||||
assert result.resources[0].attributes["encryption"] is False
|
||||
assert result.resources[1].name == "backups"
|
||||
assert result.resources[1].attributes["encryption"] is True
|
||||
|
||||
def test_discover_volumes(self):
|
||||
"""Discovers volumes from storage API."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.volumes = [
|
||||
{
|
||||
"id": "volume_1",
|
||||
"display_name": "Volume 1",
|
||||
"status": "normal",
|
||||
"fs_type": "btrfs",
|
||||
"size": {"total": "4TB", "used": "2TB"},
|
||||
"pool_path": "pool_1",
|
||||
},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_volume"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
vol = result.resources[0]
|
||||
assert vol.resource_type == "synology_volume"
|
||||
assert vol.unique_id == "synology/volume/volume_1"
|
||||
assert vol.name == "Volume 1"
|
||||
assert vol.attributes["fs_type"] == "btrfs"
|
||||
assert vol.attributes["size_total"] == "4TB"
|
||||
assert vol.raw_references == ["synology/storage_pool/pool_1"]
|
||||
|
||||
def test_discover_storage_pools(self):
|
||||
"""Discovers storage pools from storage API."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.storage_pools = [
|
||||
{
|
||||
"id": "pool_1",
|
||||
"display_name": "Storage Pool 1",
|
||||
"status": "normal",
|
||||
"raid_type": "SHR-2",
|
||||
"size": {"total": "8TB", "used": "4TB"},
|
||||
"disks": ["disk1", "disk2", "disk3", "disk4"],
|
||||
},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_storage_pool"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
pool = result.resources[0]
|
||||
assert pool.resource_type == "synology_storage_pool"
|
||||
assert pool.unique_id == "synology/storage_pool/pool_1"
|
||||
assert pool.attributes["raid_type"] == "SHR-2"
|
||||
assert pool.attributes["disk_count"] == 4
|
||||
|
||||
def test_discover_replication_tasks(self):
|
||||
"""Discovers replication tasks from replication API."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.replication.tasks = [
|
||||
{
|
||||
"id": "repl_1",
|
||||
"name": "Offsite Backup",
|
||||
"status": "active",
|
||||
"type": "snapshot_replication",
|
||||
"destination": "remote-nas.local",
|
||||
"schedule": {"frequency": "daily"},
|
||||
"shared_folder": "backups",
|
||||
},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_replication_task"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
task = result.resources[0]
|
||||
assert task.resource_type == "synology_replication_task"
|
||||
assert task.unique_id == "synology/replication_task/repl_1"
|
||||
assert task.name == "Offsite Backup"
|
||||
assert task.attributes["destination"] == "remote-nas.local"
|
||||
assert task.raw_references == ["synology/shared_folder/backups"]
|
||||
|
||||
def test_discover_users(self):
|
||||
"""Discovers users from users API."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.users.users = [
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "System administrator",
|
||||
"email": "admin@example.com",
|
||||
"expired": False,
|
||||
"groups": ["administrators"],
|
||||
},
|
||||
{
|
||||
"name": "john",
|
||||
"description": "Regular user",
|
||||
"email": "john@example.com",
|
||||
"expired": False,
|
||||
"groups": ["users"],
|
||||
},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_user"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].resource_type == "synology_user"
|
||||
assert result.resources[0].name == "admin"
|
||||
assert result.resources[0].unique_id == "synology/user/admin"
|
||||
assert result.resources[0].attributes["groups"] == ["administrators"]
|
||||
assert result.resources[1].name == "john"
|
||||
|
||||
def test_discover_multiple_resource_types(self):
|
||||
"""Discovers multiple resource types in one call."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.shares = [
|
||||
{"name": "data", "path": "/volume1/data", "desc": "", "is_encrypted": False,
|
||||
"enable_recycle_bin": True, "vol_path": "/volume1"},
|
||||
]
|
||||
plugin._api.users.users = [
|
||||
{"name": "admin", "description": "", "email": "", "expired": False, "groups": []},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_shared_folder", "synology_user"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
types = {r.resource_type for r in result.resources}
|
||||
assert types == {"synology_shared_folder", "synology_user"}
|
||||
|
||||
def test_discover_unsupported_type_adds_warning(self):
|
||||
"""Unsupported resource types produce warnings."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_nonexistent"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.warnings) == 1
|
||||
assert "synology_nonexistent" in result.warnings[0]
|
||||
|
||||
def test_discover_progress_callback_called(self):
|
||||
"""Progress callback is invoked for each resource type."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.shares = []
|
||||
|
||||
progress_updates: list[ScanProgress] = []
|
||||
plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_shared_folder", "synology_volume"],
|
||||
progress_callback=lambda p: progress_updates.append(p),
|
||||
)
|
||||
|
||||
# Should have initial + per-type + final updates
|
||||
assert len(progress_updates) >= 3
|
||||
assert progress_updates[0].total_resource_types == 2
|
||||
|
||||
def test_discover_handles_api_error_gracefully(self):
|
||||
"""API errors for one type don't prevent other types from being discovered."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
|
||||
# Storage raises an error
|
||||
type(plugin._api).storage = property(
|
||||
lambda self: (_ for _ in ()).throw(RuntimeError("API error"))
|
||||
)
|
||||
plugin._api.users = MagicMock()
|
||||
plugin._api.users.users = [
|
||||
{"name": "admin", "description": "", "email": "", "expired": False, "groups": []},
|
||||
]
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["https://nas01.local:5001"],
|
||||
resource_types=["synology_shared_folder", "synology_user"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
# Users should still be discovered even though shared folders errored
|
||||
assert len(result.errors) == 1
|
||||
assert "synology_shared_folder" in result.errors[0]
|
||||
user_resources = [r for r in result.resources if r.resource_type == "synology_user"]
|
||||
assert len(user_resources) == 1
|
||||
|
||||
def test_discover_empty_endpoints_uses_default(self):
|
||||
"""When endpoints list is empty, uses list_endpoints()."""
|
||||
plugin = self._make_authenticated_plugin()
|
||||
plugin._api.storage.shares = []
|
||||
|
||||
result = plugin.discover_resources(
|
||||
endpoints=[],
|
||||
resource_types=["synology_shared_folder"],
|
||||
progress_callback=lambda p: None,
|
||||
)
|
||||
|
||||
# Should not raise - uses default endpoint
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestSynologyPluginIsProviderPlugin:
|
||||
"""Verify SynologyPlugin properly implements ProviderPlugin ABC."""
|
||||
|
||||
def test_is_instance_of_provider_plugin(self):
|
||||
"""SynologyPlugin is a ProviderPlugin."""
|
||||
from iac_reverse.plugin_base import ProviderPlugin
|
||||
|
||||
plugin = SynologyPlugin()
|
||||
assert isinstance(plugin, ProviderPlugin)
|
||||
689
tests/unit/test_validator.py
Normal file
689
tests/unit/test_validator.py
Normal file
@@ -0,0 +1,689 @@
|
||||
"""Unit tests for the Terraform Validator.
|
||||
|
||||
Tests cover:
|
||||
- Successful validation (init + validate + plan all pass)
|
||||
- Missing terraform binary
|
||||
- terraform init failure
|
||||
- terraform validate failure with errors
|
||||
- terraform plan showing drift (planned changes)
|
||||
- Error parsing from terraform JSON output
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import PlannedChange, ValidationError, ValidationResult
|
||||
from iac_reverse.validator import Validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_completed_process(returncode=0, stdout="", stderr=""):
|
||||
"""Create a mock CompletedProcess-like object."""
|
||||
mock = MagicMock()
|
||||
mock.returncode = returncode
|
||||
mock.stdout = stdout
|
||||
mock.stderr = stderr
|
||||
return mock
|
||||
|
||||
|
||||
VALIDATE_SUCCESS_JSON = json.dumps(
|
||||
{"valid": True, "error_count": 0, "diagnostics": []}
|
||||
)
|
||||
|
||||
PLAN_NO_CHANGES_JSON = "\n".join(
|
||||
[
|
||||
json.dumps({"type": "version", "terraform": "1.7.0"}),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "change_summary",
|
||||
"changes": {"add": 0, "change": 0, "remove": 0},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: missing terraform binary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingTerraformBinary:
|
||||
def test_returns_failure_result_when_terraform_not_found(self, tmp_path):
|
||||
"""When terraform binary is absent, all success flags are False and
|
||||
a descriptive error is included."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert isinstance(result, ValidationResult)
|
||||
assert result.init_success is False
|
||||
assert result.validate_success is False
|
||||
assert result.plan_success is False
|
||||
assert len(result.errors) == 1
|
||||
error = result.errors[0]
|
||||
assert "Terraform" in error.message
|
||||
assert "required" in error.message.lower() or "PATH" in error.message
|
||||
|
||||
def test_no_planned_changes_when_terraform_not_found(self, tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.planned_changes == []
|
||||
|
||||
def test_correction_attempts_zero_when_terraform_not_found(self, tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.correction_attempts == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: terraform init failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerraformInitFailure:
|
||||
def test_init_failure_returns_correct_flags(self, tmp_path):
|
||||
"""When terraform init fails, init_success is False and subsequent
|
||||
stages are not run."""
|
||||
init_result = _make_completed_process(
|
||||
returncode=1, stderr="Error: Failed to install provider"
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", return_value=init_result
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.init_success is False
|
||||
assert result.validate_success is False
|
||||
assert result.plan_success is False
|
||||
|
||||
def test_init_failure_includes_error_message(self, tmp_path):
|
||||
init_result = _make_completed_process(
|
||||
returncode=1, stderr="Error: Failed to install provider"
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", return_value=init_result
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.errors) >= 1
|
||||
assert "terraform init failed" in result.errors[0].message.lower()
|
||||
assert "Failed to install provider" in result.errors[0].message
|
||||
|
||||
def test_init_failure_stops_pipeline(self, tmp_path):
|
||||
"""After init failure, validate and plan should not be called."""
|
||||
init_result = _make_completed_process(returncode=1, stderr="init error")
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", return_value=init_result
|
||||
) as mock_run:
|
||||
validator = Validator()
|
||||
validator.validate(str(tmp_path))
|
||||
|
||||
# Only one subprocess.run call (for init)
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: successful validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuccessfulValidation:
|
||||
def test_all_flags_true_on_success(self, tmp_path):
|
||||
"""When init, validate, and plan all succeed with zero changes,
|
||||
all success flags are True."""
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.init_success is True
|
||||
assert result.validate_success is True
|
||||
assert result.plan_success is True
|
||||
|
||||
def test_no_errors_on_success(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.errors == []
|
||||
|
||||
def test_no_planned_changes_on_success(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.planned_changes == []
|
||||
|
||||
def test_correction_attempts_zero_on_success(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.correction_attempts == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: terraform validate failure with errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerraformValidateFailure:
|
||||
def _make_validate_error_json(
|
||||
self, filename="main.tf", line=10, summary="Invalid attribute", detail="No such attribute"
|
||||
):
|
||||
return json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": summary,
|
||||
"detail": detail,
|
||||
"range": {
|
||||
"filename": filename,
|
||||
"start": {"line": line, "column": 1},
|
||||
"end": {"line": line, "column": 20},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_validate_failure_sets_correct_flags(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1, stdout=self._make_validate_error_json()
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.init_success is True
|
||||
assert result.validate_success is False
|
||||
assert result.plan_success is False
|
||||
|
||||
def test_validate_failure_parses_file_name(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=self._make_validate_error_json(filename="kubernetes_deployment.tf"),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].file == "kubernetes_deployment.tf"
|
||||
|
||||
def test_validate_failure_parses_line_number(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=self._make_validate_error_json(line=42),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.errors[0].line == 42
|
||||
|
||||
def test_validate_failure_parses_error_message(self, tmp_path):
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=self._make_validate_error_json(
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'foo' is not expected here.",
|
||||
),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert "Unsupported argument" in result.errors[0].message
|
||||
assert "foo" in result.errors[0].message
|
||||
|
||||
def test_validate_failure_multiple_errors(self, tmp_path):
|
||||
validate_json = json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 2,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Error one",
|
||||
"detail": "",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 5, "column": 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Error two",
|
||||
"detail": "",
|
||||
"range": {
|
||||
"filename": "variables.tf",
|
||||
"start": {"line": 12, "column": 3},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1, stdout=validate_json
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.errors) == 2
|
||||
assert result.errors[0].file == "main.tf"
|
||||
assert result.errors[1].file == "variables.tf"
|
||||
|
||||
def test_validate_ignores_warning_diagnostics(self, tmp_path):
|
||||
"""Only error-severity diagnostics should be included in errors."""
|
||||
validate_json = json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"summary": "Deprecated attribute",
|
||||
"detail": "Use new_attr instead.",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 3, "column": 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Real error",
|
||||
"detail": "",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 7, "column": 1},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1, stdout=validate_json
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Real error"
|
||||
|
||||
def test_validate_failure_stops_plan(self, tmp_path):
|
||||
"""When validate fails, plan should not be run."""
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=self._make_validate_error_json(),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
) as mock_run:
|
||||
validator = Validator()
|
||||
validator.validate(str(tmp_path))
|
||||
|
||||
# Only init and validate calls, no plan
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: terraform plan showing drift
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerraformPlanDrift:
|
||||
def _make_plan_with_changes(self, changes):
|
||||
"""Build a terraform plan JSON stream with the given changes.
|
||||
|
||||
changes: list of (addr, action) tuples
|
||||
"""
|
||||
lines = [json.dumps({"type": "version", "terraform": "1.7.0"})]
|
||||
for addr, action in changes:
|
||||
lines.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "planned_change",
|
||||
"change": {
|
||||
"resource": {"addr": addr},
|
||||
"action": action,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
total_add = sum(1 for _, a in changes if a == "create")
|
||||
total_change = sum(1 for _, a in changes if a == "update")
|
||||
total_remove = sum(1 for _, a in changes if a == "delete")
|
||||
lines.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "change_summary",
|
||||
"changes": {
|
||||
"add": total_add,
|
||||
"change": total_change,
|
||||
"remove": total_remove,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def test_plan_with_add_sets_plan_success_false(self, tmp_path):
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[("kubernetes_deployment.nginx", "create")]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.plan_success is False
|
||||
|
||||
def test_plan_with_add_reports_change_type_add(self, tmp_path):
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[("kubernetes_deployment.nginx", "create")]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.planned_changes) == 1
|
||||
change = result.planned_changes[0]
|
||||
assert change.resource_address == "kubernetes_deployment.nginx"
|
||||
assert change.change_type == "add"
|
||||
|
||||
def test_plan_with_update_reports_change_type_modify(self, tmp_path):
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[("docker_service.web", "update")]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.planned_changes[0].change_type == "modify"
|
||||
|
||||
def test_plan_with_delete_reports_change_type_destroy(self, tmp_path):
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[("harvester_virtualmachine.dev_vm", "delete")]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.planned_changes[0].change_type == "destroy"
|
||||
|
||||
def test_plan_with_multiple_changes(self, tmp_path):
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[
|
||||
("kubernetes_deployment.nginx", "create"),
|
||||
("docker_service.web", "update"),
|
||||
("harvester_virtualmachine.old_vm", "delete"),
|
||||
]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.planned_changes) == 3
|
||||
addresses = {c.resource_address for c in result.planned_changes}
|
||||
assert "kubernetes_deployment.nginx" in addresses
|
||||
assert "docker_service.web" in addresses
|
||||
assert "harvester_virtualmachine.old_vm" in addresses
|
||||
|
||||
def test_plan_with_changes_sets_validate_success_true(self, tmp_path):
|
||||
"""Drift does not affect validate_success — only plan_success."""
|
||||
plan_output = self._make_plan_with_changes(
|
||||
[("kubernetes_deployment.nginx", "create")]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=2, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.init_success is True
|
||||
assert result.validate_success is True
|
||||
assert result.plan_success is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: JSON parsing edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonParsing:
|
||||
def test_validate_with_invalid_json_output(self, tmp_path):
|
||||
"""When terraform validate returns non-JSON, a parse error is reported."""
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1, stdout="not valid json"
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
assert len(result.errors) >= 1
|
||||
assert any("parse" in e.message.lower() or "json" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_validate_error_without_range(self, tmp_path):
|
||||
"""Errors without range info should still be parsed with empty file and no line."""
|
||||
validate_json = json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "No range error",
|
||||
"detail": "Something went wrong",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=1, stdout=validate_json
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run", side_effect=[init_result, validate_result]
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].file == ""
|
||||
assert result.errors[0].line is None
|
||||
assert "No range error" in result.errors[0].message
|
||||
|
||||
def test_plan_with_malformed_lines_skipped(self, tmp_path):
|
||||
"""Malformed JSON lines in plan output should be skipped gracefully."""
|
||||
plan_output = "\n".join(
|
||||
[
|
||||
"not json",
|
||||
json.dumps({"type": "version", "terraform": "1.7.0"}),
|
||||
"also not json",
|
||||
json.dumps(
|
||||
{
|
||||
"type": "change_summary",
|
||||
"changes": {"add": 0, "change": 0, "remove": 0},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(returncode=0, stdout=plan_output)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
# Should not raise; plan_success True because no changes
|
||||
assert result.plan_success is True
|
||||
assert result.planned_changes == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Validator export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidatorExport:
|
||||
def test_validator_importable_from_package(self):
|
||||
from iac_reverse.validator import Validator as V
|
||||
|
||||
assert V is Validator
|
||||
|
||||
def test_validator_is_instantiable(self):
|
||||
v = Validator()
|
||||
assert v is not None
|
||||
766
tests/unit/test_validator_autocorrect.py
Normal file
766
tests/unit/test_validator_autocorrect.py
Normal file
@@ -0,0 +1,766 @@
|
||||
"""Unit tests for the Validator auto-correction loop.
|
||||
|
||||
Tests cover:
|
||||
- Successful correction on first attempt
|
||||
- Correction after multiple attempts
|
||||
- Failure after max attempts exhausted
|
||||
- correction_attempts count is accurate
|
||||
- Original errors are preserved when correction fails
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import ValidationError, ValidationResult
|
||||
from iac_reverse.validator import Validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_completed_process(returncode=0, stdout="", stderr=""):
|
||||
"""Create a mock CompletedProcess-like object."""
|
||||
mock = MagicMock()
|
||||
mock.returncode = returncode
|
||||
mock.stdout = stdout
|
||||
mock.stderr = stderr
|
||||
return mock
|
||||
|
||||
|
||||
VALIDATE_SUCCESS_JSON = json.dumps(
|
||||
{"valid": True, "error_count": 0, "diagnostics": []}
|
||||
)
|
||||
|
||||
PLAN_NO_CHANGES_JSON = "\n".join(
|
||||
[
|
||||
json.dumps({"type": "version", "terraform": "1.7.0"}),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "change_summary",
|
||||
"changes": {"add": 0, "change": 0, "remove": 0},
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _make_validate_error_json(
|
||||
filename="main.tf", line=10, summary="Unsupported argument", detail="An argument named 'bad_attr' is not expected here."
|
||||
):
|
||||
return json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": summary,
|
||||
"detail": detail,
|
||||
"range": {
|
||||
"filename": filename,
|
||||
"start": {"line": line, "column": 1},
|
||||
"end": {"line": line, "column": 20},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_missing_provider_error_json(provider_name="kubernetes"):
|
||||
return json.dumps(
|
||||
{
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Missing required provider",
|
||||
"detail": f"provider \"{provider_name}\" configuration not present",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 1, "column": 1},
|
||||
"end": {"line": 1, "column": 20},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Successful correction on first attempt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuccessfulCorrectionFirstAttempt:
|
||||
"""Validation fails initially but succeeds after one correction attempt."""
|
||||
|
||||
def test_removes_unknown_attribute_and_passes(self, tmp_path):
|
||||
"""When an unknown attribute error occurs, the offending line is removed
|
||||
and re-validation succeeds on the first attempt."""
|
||||
# Create a .tf file with a bad attribute on line 3
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' ami = "ami-123"\n'
|
||||
' bad_attr = "should be removed"\n'
|
||||
' instance_type = "t2.micro"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
# First validate fails with unknown attribute on line 3
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad_attr' is not expected here.",
|
||||
),
|
||||
)
|
||||
# Second validate succeeds after correction
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail, validate_success, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.init_success is True
|
||||
assert result.validate_success is True
|
||||
assert result.correction_attempts == 1
|
||||
|
||||
# Verify the file was corrected
|
||||
content = tf_file.read_text()
|
||||
assert "bad_attr" not in content
|
||||
assert "ami" in content
|
||||
assert "instance_type" in content
|
||||
|
||||
def test_adds_missing_provider_block_and_passes(self, tmp_path):
|
||||
"""When a missing provider error occurs, an empty provider block is added
|
||||
and re-validation succeeds."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "kubernetes_deployment" "app" {\n'
|
||||
' metadata {\n'
|
||||
' name = "app"\n'
|
||||
' }\n'
|
||||
'}\n'
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_missing_provider_error_json("kubernetes"),
|
||||
)
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail, validate_success, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is True
|
||||
assert result.correction_attempts == 1
|
||||
|
||||
# Verify provider block was added
|
||||
providers_file = tmp_path / "providers.tf"
|
||||
assert providers_file.exists()
|
||||
content = providers_file.read_text()
|
||||
assert 'provider "kubernetes"' in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Correction after multiple attempts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrectionAfterMultipleAttempts:
|
||||
"""Validation requires multiple correction attempts before succeeding."""
|
||||
|
||||
def test_succeeds_after_two_correction_attempts(self, tmp_path):
|
||||
"""Two different errors require two correction passes."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' ami = "ami-123"\n'
|
||||
' bad_attr1 = "remove me"\n'
|
||||
' instance_type = "t2.micro"\n'
|
||||
' bad_attr2 = "also remove me"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
# First validate: error on line 3 (bad_attr1)
|
||||
validate_fail_1 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad_attr1' is not expected here.",
|
||||
),
|
||||
)
|
||||
# Second validate: error on line 4 (bad_attr2 is now on line 4 after removal)
|
||||
validate_fail_2 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=4,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad_attr2' is not expected here.",
|
||||
),
|
||||
)
|
||||
# Third validate succeeds
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
validate_fail_1,
|
||||
validate_fail_2,
|
||||
validate_success,
|
||||
plan_result,
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is True
|
||||
assert result.correction_attempts == 2
|
||||
|
||||
def test_succeeds_on_third_attempt(self, tmp_path):
|
||||
"""Validation succeeds on the third (max) correction attempt."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' ami = "ami-123"\n'
|
||||
' bad1 = "x"\n'
|
||||
' bad2 = "y"\n'
|
||||
' bad3 = "z"\n'
|
||||
' instance_type = "t2.micro"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
validate_fail_1 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf", line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad1' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_fail_2 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf", line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad2' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_fail_3 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf", line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad3' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
validate_fail_1,
|
||||
validate_fail_2,
|
||||
validate_fail_3,
|
||||
validate_success,
|
||||
plan_result,
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is True
|
||||
assert result.correction_attempts == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Failure after max attempts exhausted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFailureAfterMaxAttempts:
|
||||
"""Validation still fails after all correction attempts are exhausted."""
|
||||
|
||||
def test_fails_after_max_attempts_with_uncorrectable_error(self, tmp_path):
|
||||
"""When errors cannot be corrected, fails after max attempts."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' ami = "ami-123"\n'
|
||||
' bad_attr = "remove me"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
# Each validate returns the same error (correction removes line but
|
||||
# new errors keep appearing)
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=3,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad_attr' is not expected here.",
|
||||
),
|
||||
)
|
||||
# After first correction removes line 3, subsequent validates still fail
|
||||
# with a different uncorrectable error (no file/line info)
|
||||
validate_fail_no_fix = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=json.dumps({
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Some complex error",
|
||||
"detail": "Cannot be auto-corrected",
|
||||
}
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
validate_fail,
|
||||
validate_fail_no_fix, # After first correction, new error with no file
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
# Stopped after 1 attempt because the second error has no file info
|
||||
# and cannot be corrected
|
||||
assert result.correction_attempts >= 1
|
||||
|
||||
def test_fails_with_max_3_attempts_default(self, tmp_path):
|
||||
"""With default max_correction_attempts=3, stops after 3 attempts."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
# Write a file where the error line keeps being valid for removal
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' attr1 = "a"\n'
|
||||
' attr2 = "b"\n'
|
||||
' attr3 = "c"\n'
|
||||
' attr4 = "d"\n'
|
||||
' attr5 = "e"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
def make_fail(line, attr):
|
||||
return _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=line,
|
||||
summary="Unsupported argument",
|
||||
detail=f"An argument named '{attr}' is not expected here.",
|
||||
),
|
||||
)
|
||||
|
||||
# 4 failures: first 3 get corrected, 4th is returned as final failure
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
make_fail(2, "attr1"),
|
||||
make_fail(2, "attr2"),
|
||||
make_fail(2, "attr3"),
|
||||
make_fail(2, "attr4"), # This one exceeds max attempts
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
assert result.correction_attempts == 3
|
||||
|
||||
def test_custom_max_attempts_respected(self, tmp_path):
|
||||
"""Custom max_correction_attempts value is respected."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' attr1 = "a"\n'
|
||||
' attr2 = "b"\n'
|
||||
' attr3 = "c"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
def make_fail(line, attr):
|
||||
return _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=line,
|
||||
summary="Unsupported argument",
|
||||
detail=f"An argument named '{attr}' is not expected here.",
|
||||
),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
make_fail(2, "attr1"),
|
||||
make_fail(2, "attr2"), # Exceeds max_correction_attempts=1
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path), max_correction_attempts=1)
|
||||
|
||||
assert result.validate_success is False
|
||||
assert result.correction_attempts == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: correction_attempts count is accurate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCorrectionAttemptsCount:
|
||||
"""The correction_attempts field accurately reflects the number of attempts."""
|
||||
|
||||
def test_zero_attempts_when_validation_passes_immediately(self, tmp_path):
|
||||
"""No correction attempts when validation passes on first try."""
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_result = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_result, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.correction_attempts == 0
|
||||
|
||||
def test_one_attempt_when_first_correction_fixes(self, tmp_path):
|
||||
"""correction_attempts is 1 when first correction resolves the issue."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "null_resource" "test" {\n'
|
||||
' unknown_field = "value"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=2,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'unknown_field' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail, validate_success, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.correction_attempts == 1
|
||||
|
||||
def test_attempts_count_matches_actual_corrections_applied(self, tmp_path):
|
||||
"""correction_attempts matches the number of correction loops executed."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "null_resource" "test" {\n'
|
||||
' bad1 = "x"\n'
|
||||
' bad2 = "y"\n'
|
||||
' good = "keep"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_fail_1 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf", line=2,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad1' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_fail_2 = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf", line=2,
|
||||
summary="Unsupported argument",
|
||||
detail="An argument named 'bad2' is not expected here.",
|
||||
),
|
||||
)
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
validate_fail_1,
|
||||
validate_fail_2,
|
||||
validate_success,
|
||||
plan_result,
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.correction_attempts == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Original errors preserved when correction fails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOriginalErrorsPreserved:
|
||||
"""When correction fails, the remaining errors are reported accurately."""
|
||||
|
||||
def test_errors_from_final_validation_are_returned(self, tmp_path):
|
||||
"""After exhausting attempts, the errors from the last validation run
|
||||
are returned in the result."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "aws_instance" "example" {\n'
|
||||
' attr1 = "a"\n'
|
||||
' attr2 = "b"\n'
|
||||
' attr3 = "c"\n'
|
||||
' attr4 = "d"\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
def make_fail(attr):
|
||||
return _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=_make_validate_error_json(
|
||||
filename="main.tf",
|
||||
line=2,
|
||||
summary="Unsupported argument",
|
||||
detail=f"An argument named '{attr}' is not expected here.",
|
||||
),
|
||||
)
|
||||
|
||||
# 3 corrections + final failure
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[
|
||||
init_result,
|
||||
make_fail("attr1"),
|
||||
make_fail("attr2"),
|
||||
make_fail("attr3"),
|
||||
make_fail("attr4"),
|
||||
],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
assert len(result.errors) >= 1
|
||||
# The final error should be about attr4
|
||||
assert "attr4" in result.errors[0].message
|
||||
|
||||
def test_uncorrectable_errors_preserved_with_file_info(self, tmp_path):
|
||||
"""Errors that cannot be corrected retain their file and line info."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text('invalid terraform content\n')
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
# Error that doesn't match any correction pattern
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=json.dumps({
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Completely unknown error type",
|
||||
"detail": "This cannot be auto-corrected at all",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 1, "column": 1},
|
||||
"end": {"line": 1, "column": 10},
|
||||
},
|
||||
}
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
assert result.correction_attempts == 0 # No correction was possible
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].file == "main.tf"
|
||||
assert result.errors[0].line == 1
|
||||
assert "Completely unknown error type" in result.errors[0].message
|
||||
|
||||
def test_no_correction_when_error_has_no_file(self, tmp_path):
|
||||
"""Errors without file information cannot be corrected."""
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=json.dumps({
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Unsupported argument",
|
||||
"detail": "An argument named 'foo' is not expected here.",
|
||||
}
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is False
|
||||
# No file info means no correction can be applied
|
||||
assert result.correction_attempts == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Syntax error correction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyntaxErrorCorrection:
|
||||
"""Tests for syntax error auto-correction."""
|
||||
|
||||
def test_trailing_comma_fixed(self, tmp_path):
|
||||
"""Trailing commas before closing braces are removed."""
|
||||
tf_file = tmp_path / "main.tf"
|
||||
tf_file.write_text(
|
||||
'resource "null_resource" "test" {\n'
|
||||
' triggers = {\n'
|
||||
' key = "value",\n'
|
||||
' }\n'
|
||||
'}\n'
|
||||
)
|
||||
|
||||
init_result = _make_completed_process(returncode=0)
|
||||
validate_fail = _make_completed_process(
|
||||
returncode=1,
|
||||
stdout=json.dumps({
|
||||
"valid": False,
|
||||
"error_count": 1,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "error",
|
||||
"summary": "Invalid character",
|
||||
"detail": "trailing comma not allowed",
|
||||
"range": {
|
||||
"filename": "main.tf",
|
||||
"start": {"line": 3, "column": 18},
|
||||
"end": {"line": 3, "column": 19},
|
||||
},
|
||||
}
|
||||
],
|
||||
}),
|
||||
)
|
||||
validate_success = _make_completed_process(
|
||||
returncode=0, stdout=VALIDATE_SUCCESS_JSON
|
||||
)
|
||||
plan_result = _make_completed_process(
|
||||
returncode=0, stdout=PLAN_NO_CHANGES_JSON
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/terraform"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=[init_result, validate_fail, validate_success, plan_result],
|
||||
):
|
||||
validator = Validator()
|
||||
result = validator.validate(str(tmp_path))
|
||||
|
||||
assert result.validate_success is True
|
||||
assert result.correction_attempts == 1
|
||||
|
||||
content = tf_file.read_text()
|
||||
assert ",\n }" not in content
|
||||
405
tests/unit/test_variable_extractor.py
Normal file
405
tests/unit/test_variable_extractor.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""Unit tests for the VariableExtractor."""
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import (
|
||||
CpuArchitecture,
|
||||
DiscoveredResource,
|
||||
ExtractedVariable,
|
||||
PlatformCategory,
|
||||
ProviderType,
|
||||
)
|
||||
from iac_reverse.generator import VariableExtractor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
) -> 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=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: No shared values produces no variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoSharedValues:
|
||||
"""Tests that no variables are produced when values are not shared."""
|
||||
|
||||
def test_empty_resources_produces_no_variables(self):
|
||||
"""An empty resource list produces no variables."""
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([])
|
||||
assert result == []
|
||||
|
||||
def test_single_resource_produces_no_variables(self):
|
||||
"""A single resource cannot have shared values."""
|
||||
resource = make_resource(attributes={"namespace": "default", "replicas": 3})
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([resource])
|
||||
assert result == []
|
||||
|
||||
def test_two_resources_no_common_values_produces_no_variables(self):
|
||||
"""Two resources with completely different attribute values produce no variables."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1",
|
||||
name="app-a",
|
||||
attributes={"namespace": "alpha", "replicas": 1},
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2",
|
||||
name="app-b",
|
||||
attributes={"namespace": "beta", "replicas": 2},
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
assert result == []
|
||||
|
||||
def test_two_resources_same_key_different_values_no_variable(self):
|
||||
"""Two resources with the same key but different values produce no variable."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1",
|
||||
name="app-a",
|
||||
attributes={"environment": "staging"},
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2",
|
||||
name="app-b",
|
||||
attributes={"environment": "production"},
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Value appearing in 2 resources produces a variable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharedValueExtraction:
|
||||
"""Tests that shared values are correctly extracted as variables."""
|
||||
|
||||
def test_same_value_in_two_resources_produces_variable(self):
|
||||
"""A value appearing in 2 resources for the same key produces a variable."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1",
|
||||
name="app-a",
|
||||
attributes={"namespace": "production"},
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2",
|
||||
name="app-b",
|
||||
attributes={"namespace": "production"},
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "var_namespace"
|
||||
|
||||
def test_same_value_in_three_resources_produces_one_variable(self):
|
||||
"""A value appearing in 3 resources still produces exactly one variable."""
|
||||
resources = [
|
||||
make_resource(
|
||||
unique_id=f"r{i}",
|
||||
name=f"app-{i}",
|
||||
attributes={"region": "us-east-1"},
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables(resources)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "var_region"
|
||||
|
||||
def test_multiple_shared_keys_produce_multiple_variables(self):
|
||||
"""Multiple shared attribute keys each produce their own variable."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1",
|
||||
name="app-a",
|
||||
attributes={"namespace": "default", "environment": "prod"},
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2",
|
||||
name="app-b",
|
||||
attributes={"namespace": "default", "environment": "prod"},
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
var_names = {v.name for v in result}
|
||||
assert "var_namespace" in var_names
|
||||
assert "var_environment" in var_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Default is set to most common value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultValue:
|
||||
"""Tests that the default value is set to the most common value."""
|
||||
|
||||
def test_default_is_most_common_value(self):
|
||||
"""When only one value is shared (2+ resources), default is that value."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"namespace": "production"}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"namespace": "production"}
|
||||
)
|
||||
r3 = make_resource(
|
||||
unique_id="r3", name="app-c", attributes={"namespace": "staging"}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2, r3])
|
||||
|
||||
# "production" appears in 2 resources, "staging" in 1 (not shared)
|
||||
# The variable for "production" should have default = "production"
|
||||
assert len(result) == 1
|
||||
assert result[0].default_value == '"production"'
|
||||
|
||||
def test_default_with_equal_counts_picks_one(self):
|
||||
"""When values have equal counts, each variable gets its own value as default."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"namespace": "alpha"}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"namespace": "alpha"}
|
||||
)
|
||||
r3 = make_resource(
|
||||
unique_id="r3", name="app-c", attributes={"namespace": "beta"}
|
||||
)
|
||||
r4 = make_resource(
|
||||
unique_id="r4", name="app-d", attributes={"namespace": "beta"}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2, r3, r4])
|
||||
|
||||
# Both "alpha" and "beta" appear in 2 resources each
|
||||
# Both should produce variables; each with its own value as default
|
||||
assert len(result) == 2
|
||||
defaults = {v.default_value for v in result}
|
||||
assert '"alpha"' in defaults
|
||||
assert '"beta"' in defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Type expression matches value type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTypeExpression:
|
||||
"""Tests that type expressions match the Python value type."""
|
||||
|
||||
def test_string_value_has_string_type(self):
|
||||
"""A string attribute value produces type = string."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"namespace": "default"}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"namespace": "default"}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert result[0].type_expr == "string"
|
||||
|
||||
def test_integer_value_has_number_type(self):
|
||||
"""An integer attribute value produces type = number."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"replicas": 3}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"replicas": 3}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert result[0].type_expr == "number"
|
||||
|
||||
def test_boolean_value_has_bool_type(self):
|
||||
"""A boolean attribute value produces type = bool."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"enabled": True}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"enabled": True}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert result[0].type_expr == "bool"
|
||||
|
||||
def test_number_default_format(self):
|
||||
"""A number default is formatted without quotes."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"replicas": 3}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"replicas": 3}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert result[0].default_value == "3"
|
||||
|
||||
def test_bool_default_format(self):
|
||||
"""A boolean default is formatted as true/false."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"enabled": False}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"enabled": False}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert result[0].default_value == "false"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: used_by lists all resources using the variable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsedBy:
|
||||
"""Tests that used_by correctly lists all resources using the variable."""
|
||||
|
||||
def test_used_by_contains_both_resource_ids(self):
|
||||
"""used_by lists both resource unique_ids that share the value."""
|
||||
r1 = make_resource(
|
||||
unique_id="ns/deployments/app-a",
|
||||
name="app-a",
|
||||
attributes={"namespace": "production"},
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="ns/deployments/app-b",
|
||||
name="app-b",
|
||||
attributes={"namespace": "production"},
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2])
|
||||
|
||||
assert len(result) == 1
|
||||
assert "ns/deployments/app-a" in result[0].used_by
|
||||
assert "ns/deployments/app-b" in result[0].used_by
|
||||
|
||||
def test_used_by_contains_all_three_resource_ids(self):
|
||||
"""used_by lists all three resource unique_ids when value is shared by 3."""
|
||||
resources = [
|
||||
make_resource(
|
||||
unique_id=f"ns/deployments/app-{i}",
|
||||
name=f"app-{i}",
|
||||
attributes={"environment": "prod"},
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables(resources)
|
||||
|
||||
assert len(result[0].used_by) == 3
|
||||
for i in range(3):
|
||||
assert f"ns/deployments/app-{i}" in result[0].used_by
|
||||
|
||||
def test_used_by_excludes_resources_with_different_value(self):
|
||||
"""used_by does not include resources that have a different value for the key."""
|
||||
r1 = make_resource(
|
||||
unique_id="r1", name="app-a", attributes={"namespace": "production"}
|
||||
)
|
||||
r2 = make_resource(
|
||||
unique_id="r2", name="app-b", attributes={"namespace": "production"}
|
||||
)
|
||||
r3 = make_resource(
|
||||
unique_id="r3", name="app-c", attributes={"namespace": "staging"}
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.extract_variables([r1, r2, r3])
|
||||
|
||||
# Only the "production" variable should exist (2 resources)
|
||||
assert len(result) == 1
|
||||
assert "r1" in result[0].used_by
|
||||
assert "r2" in result[0].used_by
|
||||
assert "r3" not in result[0].used_by
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: generate_variables_tf output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerateVariablesTf:
|
||||
"""Tests for the variables.tf file content generation."""
|
||||
|
||||
def test_empty_variables_produces_empty_string(self):
|
||||
"""No variables produces an empty string."""
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.generate_variables_tf([])
|
||||
assert result == ""
|
||||
|
||||
def test_single_variable_produces_valid_block(self):
|
||||
"""A single variable produces a valid Terraform variable block."""
|
||||
var = ExtractedVariable(
|
||||
name="var_namespace",
|
||||
type_expr="string",
|
||||
default_value='"production"',
|
||||
description="Shared namespace value extracted from 2 resources",
|
||||
used_by=["r1", "r2"],
|
||||
)
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.generate_variables_tf([var])
|
||||
|
||||
assert 'variable "var_namespace"' in result
|
||||
assert "type = string" in result
|
||||
assert 'description = "Shared namespace value extracted from 2 resources"' in result
|
||||
assert 'default = "production"' in result
|
||||
|
||||
def test_multiple_variables_separated_by_blank_line(self):
|
||||
"""Multiple variables are separated by blank lines."""
|
||||
vars_list = [
|
||||
ExtractedVariable(
|
||||
name="var_namespace",
|
||||
type_expr="string",
|
||||
default_value='"default"',
|
||||
description="Shared namespace",
|
||||
used_by=["r1", "r2"],
|
||||
),
|
||||
ExtractedVariable(
|
||||
name="var_replicas",
|
||||
type_expr="number",
|
||||
default_value="3",
|
||||
description="Shared replicas",
|
||||
used_by=["r1", "r2"],
|
||||
),
|
||||
]
|
||||
extractor = VariableExtractor()
|
||||
result = extractor.generate_variables_tf(vars_list)
|
||||
|
||||
assert 'variable "var_namespace"' in result
|
||||
assert 'variable "var_replicas"' in result
|
||||
# Two blocks separated by a blank line
|
||||
assert "\n\n" in result
|
||||
529
tests/unit/test_windows_plugin.py
Normal file
529
tests/unit/test_windows_plugin.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""Unit tests for the Windows Discovery Plugin.
|
||||
|
||||
Tests use mocks for the winrm session to avoid requiring actual
|
||||
Windows hosts for testing.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from iac_reverse.models import CpuArchitecture, PlatformCategory, ProviderType
|
||||
from iac_reverse.scanner.scanner import AuthenticationError
|
||||
from iac_reverse.scanner.windows_plugin import (
|
||||
InsufficientPrivilegesError,
|
||||
WindowsDiscoveryPlugin,
|
||||
WinRMNotEnabledError,
|
||||
WMIQueryError,
|
||||
WINDOWS_RESOURCE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin():
|
||||
"""Create a fresh WindowsDiscoveryPlugin instance."""
|
||||
return WindowsDiscoveryPlugin()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credentials():
|
||||
"""Standard test credentials."""
|
||||
return {
|
||||
"host": "192.168.1.100",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"transport": "ntlm",
|
||||
"port": "5986",
|
||||
"use_ssl": "true",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock WinRM session."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
def make_ps_result(stdout: str = "", stderr: str = "", status_code: int = 0):
|
||||
"""Helper to create a mock PowerShell result."""
|
||||
result = MagicMock()
|
||||
result.std_out = stdout.encode("utf-8")
|
||||
result.std_err = stderr.encode("utf-8")
|
||||
result.status_code = status_code
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""Tests for WindowsDiscoveryPlugin.authenticate()."""
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_authenticate_success(self, mock_session_cls, plugin, credentials):
|
||||
"""Successful authentication creates a session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.return_value = make_ps_result("WIN-SERVER01")
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
|
||||
mock_session_cls.assert_called_once_with(
|
||||
"https://192.168.1.100:5986/wsman",
|
||||
auth=("admin", "secret"),
|
||||
transport="ntlm",
|
||||
server_cert_validation="ignore",
|
||||
)
|
||||
assert plugin._host == "192.168.1.100"
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_authenticate_http_no_ssl(self, mock_session_cls, plugin):
|
||||
"""Authentication with use_ssl=false uses HTTP."""
|
||||
creds = {
|
||||
"host": "myhost",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"transport": "ntlm",
|
||||
"port": "5985",
|
||||
"use_ssl": "false",
|
||||
}
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.return_value = make_ps_result("MYHOST")
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(creds)
|
||||
|
||||
mock_session_cls.assert_called_once_with(
|
||||
"http://myhost:5985/wsman",
|
||||
auth=("user", "pass"),
|
||||
transport="ntlm",
|
||||
server_cert_validation="validate",
|
||||
)
|
||||
|
||||
def test_authenticate_missing_host(self, plugin):
|
||||
"""Missing host raises AuthenticationError."""
|
||||
creds = {"username": "user", "password": "pass"}
|
||||
with pytest.raises(AuthenticationError, match="host is required"):
|
||||
plugin.authenticate(creds)
|
||||
|
||||
def test_authenticate_missing_username(self, plugin):
|
||||
"""Missing username raises AuthenticationError."""
|
||||
creds = {"host": "myhost", "password": "pass"}
|
||||
with pytest.raises(AuthenticationError, match="username is required"):
|
||||
plugin.authenticate(creds)
|
||||
|
||||
def test_authenticate_missing_password(self, plugin):
|
||||
"""Missing password raises AuthenticationError."""
|
||||
creds = {"host": "myhost", "username": "user"}
|
||||
with pytest.raises(AuthenticationError, match="password is required"):
|
||||
plugin.authenticate(creds)
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_authenticate_connection_refused(self, mock_session_cls, plugin, credentials):
|
||||
"""Connection refused raises WinRMNotEnabledError."""
|
||||
mock_session_cls.side_effect = Exception("connection refused")
|
||||
|
||||
with pytest.raises(WinRMNotEnabledError):
|
||||
plugin.authenticate(credentials)
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_authenticate_access_denied(self, mock_session_cls, plugin, credentials):
|
||||
"""Access denied during auth test raises AuthenticationError."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.return_value = make_ps_result(
|
||||
stderr="Access is denied", status_code=1
|
||||
)
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
plugin.authenticate(credentials)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform Category and Resource Types Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformInfo:
|
||||
"""Tests for platform category and resource type listing."""
|
||||
|
||||
def test_get_platform_category(self, plugin):
|
||||
"""Returns PlatformCategory.WINDOWS."""
|
||||
assert plugin.get_platform_category() == PlatformCategory.WINDOWS
|
||||
|
||||
def test_list_supported_resource_types(self, plugin):
|
||||
"""Returns all 13 Windows resource types."""
|
||||
types = plugin.list_supported_resource_types()
|
||||
assert len(types) == 13
|
||||
assert "windows_service" in types
|
||||
assert "windows_scheduled_task" in types
|
||||
assert "windows_iis_site" in types
|
||||
assert "windows_iis_app_pool" in types
|
||||
assert "windows_network_adapter" in types
|
||||
assert "windows_firewall_rule" in types
|
||||
assert "windows_installed_software" in types
|
||||
assert "windows_feature" in types
|
||||
assert "windows_hyperv_vm" in types
|
||||
assert "windows_hyperv_switch" in types
|
||||
assert "windows_dns_record" in types
|
||||
assert "windows_local_user" in types
|
||||
assert "windows_local_group" in types
|
||||
|
||||
def test_list_endpoints_before_auth(self, plugin):
|
||||
"""Returns empty list before authentication."""
|
||||
assert plugin.list_endpoints() == []
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_list_endpoints_after_auth(self, mock_session_cls, plugin, credentials):
|
||||
"""Returns host after authentication."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.return_value = make_ps_result("SERVER")
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
assert plugin.list_endpoints() == ["192.168.1.100"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Architecture Detection Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectArchitecture:
|
||||
"""Tests for CPU architecture detection via WMI."""
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_detect_amd64(self, mock_session_cls, plugin, credentials):
|
||||
"""Architecture code 9 maps to AMD64."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"), # auth test
|
||||
make_ps_result("9"), # architecture query
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
arch = plugin.detect_architecture("192.168.1.100")
|
||||
assert arch == CpuArchitecture.AMD64
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_detect_arm(self, mock_session_cls, plugin, credentials):
|
||||
"""Architecture code 5 maps to ARM."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("5"),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
arch = plugin.detect_architecture("192.168.1.100")
|
||||
assert arch == CpuArchitecture.ARM
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_detect_aarch64(self, mock_session_cls, plugin, credentials):
|
||||
"""Architecture code 12 maps to AARCH64."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("12"),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
arch = plugin.detect_architecture("192.168.1.100")
|
||||
assert arch == CpuArchitecture.AARCH64
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_detect_architecture_wmi_failure(self, mock_session_cls, plugin, credentials):
|
||||
"""WMI query failure raises WMIQueryError."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result(stderr="WMI error", status_code=1),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
with pytest.raises(WMIQueryError):
|
||||
plugin.detect_architecture("192.168.1.100")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource Discovery Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoverResources:
|
||||
"""Tests for resource discovery via WinRM."""
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_services(self, mock_session_cls, plugin, credentials):
|
||||
"""Discovers Windows services."""
|
||||
services_json = json.dumps([
|
||||
{"Name": "wuauserv", "DisplayName": "Windows Update", "Status": 4, "StartType": 3},
|
||||
{"Name": "Spooler", "DisplayName": "Print Spooler", "Status": 4, "StartType": 2},
|
||||
])
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"), # auth
|
||||
make_ps_result("9"), # architecture
|
||||
make_ps_result("false"), # hyperv check
|
||||
make_ps_result(services_json), # services
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_service"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 2
|
||||
assert result.resources[0].resource_type == "windows_service"
|
||||
assert result.resources[0].name == "wuauserv"
|
||||
assert result.resources[0].provider == ProviderType.WINDOWS
|
||||
assert result.resources[0].platform_category == PlatformCategory.WINDOWS
|
||||
assert result.resources[0].architecture == CpuArchitecture.AMD64
|
||||
callback.assert_called()
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_scheduled_tasks(self, mock_session_cls, plugin, credentials):
|
||||
"""Discovers scheduled tasks."""
|
||||
tasks_json = json.dumps([
|
||||
{"TaskName": "Backup", "TaskPath": "\\Custom\\", "State": 3},
|
||||
])
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"),
|
||||
make_ps_result(tasks_json),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_scheduled_task"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "windows_scheduled_task"
|
||||
assert result.resources[0].name == "Backup"
|
||||
assert result.resources[0].attributes["task_path"] == "\\Custom\\"
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_hyperv_skipped_when_not_installed(
|
||||
self, mock_session_cls, plugin, credentials
|
||||
):
|
||||
"""Hyper-V resources are skipped when role is not installed."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"), # hyperv NOT installed
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_hyperv_vm"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert any("Hyper-V role not installed" in w for w in result.warnings)
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_hyperv_vms_when_installed(
|
||||
self, mock_session_cls, plugin, credentials
|
||||
):
|
||||
"""Hyper-V VMs are discovered when role is installed."""
|
||||
vms_json = json.dumps([
|
||||
{
|
||||
"Name": "TestVM",
|
||||
"VMId": "abc-123",
|
||||
"State": 2,
|
||||
"MemoryAssigned": 4294967296,
|
||||
"ProcessorCount": 4,
|
||||
"Generation": 2,
|
||||
}
|
||||
])
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("true"), # hyperv IS installed
|
||||
make_ps_result(vms_json),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_hyperv_vm"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "windows_hyperv_vm"
|
||||
assert result.resources[0].name == "TestVM"
|
||||
assert result.resources[0].attributes["vm_id"] == "abc-123"
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_local_users(self, mock_session_cls, plugin, credentials):
|
||||
"""Discovers local user accounts."""
|
||||
users_json = json.dumps([
|
||||
{"Name": "Administrator", "Enabled": True, "Description": "Built-in admin", "LastLogon": "2024-01-01"},
|
||||
])
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"),
|
||||
make_ps_result(users_json),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_local_user"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 1
|
||||
assert result.resources[0].resource_type == "windows_local_user"
|
||||
assert result.resources[0].name == "Administrator"
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_insufficient_privileges(
|
||||
self, mock_session_cls, plugin, credentials
|
||||
):
|
||||
"""Insufficient privileges are captured as errors."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"),
|
||||
make_ps_result(stderr="Access is denied", status_code=1),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_service"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "Insufficient privileges" in result.errors[0]
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_wmi_query_failure(
|
||||
self, mock_session_cls, plugin, credentials
|
||||
):
|
||||
"""WMI query failures are captured as errors."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"),
|
||||
make_ps_result(stderr="Invalid class", status_code=1),
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_feature"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "WMI query failed" in result.errors[0]
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_discover_empty_result(self, mock_session_cls, plugin, credentials):
|
||||
"""Empty PowerShell output returns no resources."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.run_ps.side_effect = [
|
||||
make_ps_result("SERVER"),
|
||||
make_ps_result("9"),
|
||||
make_ps_result("false"),
|
||||
make_ps_result(""), # empty output
|
||||
]
|
||||
mock_session_cls.return_value = mock_session
|
||||
|
||||
plugin.authenticate(credentials)
|
||||
callback = MagicMock()
|
||||
result = plugin.discover_resources(
|
||||
endpoints=["192.168.1.100"],
|
||||
resource_types=["windows_local_group"],
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(result.resources) == 0
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error Handling Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Tests for WinRM-specific error handling."""
|
||||
|
||||
def test_winrm_not_enabled_error(self):
|
||||
"""WinRMNotEnabledError contains host info."""
|
||||
err = WinRMNotEnabledError("myhost", "connection refused")
|
||||
assert "myhost" in str(err)
|
||||
assert "connection refused" in str(err)
|
||||
assert err.host == "myhost"
|
||||
|
||||
def test_wmi_query_error(self):
|
||||
"""WMIQueryError contains query info."""
|
||||
err = WMIQueryError("Win32_Processor", "invalid class")
|
||||
assert "Win32_Processor" in str(err)
|
||||
assert "invalid class" in str(err)
|
||||
assert err.query == "Win32_Processor"
|
||||
|
||||
def test_insufficient_privileges_error(self):
|
||||
"""InsufficientPrivilegesError contains operation info."""
|
||||
err = InsufficientPrivilegesError("Get-Service", "access denied")
|
||||
assert "Get-Service" in str(err)
|
||||
assert "access denied" in str(err)
|
||||
assert err.operation == "Get-Service"
|
||||
|
||||
@patch("iac_reverse.scanner.windows_plugin.winrm.Session")
|
||||
def test_no_session_raises_winrm_not_enabled(
|
||||
self, mock_session_cls, plugin
|
||||
):
|
||||
"""Running PowerShell without session raises WinRMNotEnabledError."""
|
||||
plugin._host = "testhost"
|
||||
with pytest.raises(WinRMNotEnabledError):
|
||||
plugin._run_powershell("Get-Service")
|
||||
Reference in New Issue
Block a user