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