Created IAC reverse generator
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user