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

665 lines
24 KiB
Python

"""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