"""Unit tests for the RisPort70 SOAP envelope construction and parser. Live-cluster integration is verified separately via the smoke-test script — these tests are pure: envelope shape, response-XML parsing, edge cases. No network. """ import xml.etree.ElementTree as ET import pytest from mcp_cucm_axl.risport import ( DEVICE_STATUS_VALUES, RisPortClient, _build_select_envelope, _escape_xml, _parse_response, ) class TestEscapeXml: def test_basic_escapes(self): assert _escape_xml("") == "<bad>" assert _escape_xml("a&b") == "a&b" assert _escape_xml('"x"') == ""x"" assert _escape_xml("'y'") == "'y'" def test_passthrough_safe_text(self): assert _escape_xml("Phone-1234") == "Phone-1234" class TestSelectEnvelope: def test_envelope_is_well_formed_xml(self): env = _build_select_envelope() # Must parse — if not, RisPort will reject it root = ET.fromstring(env) assert root is not None def test_default_envelope_includes_required_fields(self): env = _build_select_envelope() # Cisco's WSDL requires every CmSelectionCriteria child for required in [ "MaxReturnedDevices", "DeviceClass", "Model", "Status", "NodeName", "SelectBy", "SelectItems", "Protocol", "DownloadStatus", ]: assert f"" in env, ( f"missing required CmSelectionCriteria field " ) def test_state_info_threaded_into_envelope(self): env = _build_select_envelope(state_info="cursor-abc-123") assert "cursor-abc-123" in env assert "cursor-abc-123" in env def test_select_items_default_wildcard(self): env = _build_select_envelope() # Default: ['*'] — all devices assert "*" in env def test_select_items_explicit_list(self): env = _build_select_envelope(select_items=["SEP1", "SEP2"]) assert "SEP1" in env assert "SEP2" in env def test_max_devices_int_coerced(self): env = _build_select_envelope(max_devices=500) assert "500" in env def test_xml_special_chars_in_filter_escaped(self): # SOAP injection defense — single quote and angle bracket must be escaped env = _build_select_envelope(select_items=["O'Brien "]) assert "'" in env assert "<" in env assert ">" in env # The raw chars must NOT appear assert "'Brien " not in env class TestParseResponse: # Match CUCM 15's actual response shape: an extra # wrapper inside . Discovered while live-verifying # against cucm-pub.binghammemorial.org 2026-04-26 — the parser must # descend through this wrapper. SAMPLE_RESPONSE = """ 2 cucm-pub Ok SEP1234567890AB 10.0.0.5ipv4 2001 Registered 0 SIP Patient Room 101 Cisco 7841 sip78xx.12-5-1SR4-3 1700000000 SEPABCDEF123456 UnRegistered 1 Decommissioned phone """ # Pre-CUCM-15 shape (no SelectCmDeviceResult wrapper). The parser falls # back to fields directly under selectCmDeviceReturn for backward compat. LEGACY_RESPONSE = """ 1 cucm-old Ok SEPLEGACY Registered """ def test_legacy_response_shape_still_parses(self): """Backward compat with pre-CUCM-15 RisPort responses.""" result = _parse_response(self.LEGACY_RESPONSE) assert result["total_devices_found"] == 1 assert result["cm_nodes"][0]["devices"][0]["name"] == "SEPLEGACY" def test_parse_total_count(self): result = _parse_response(self.SAMPLE_RESPONSE) assert result["total_devices_found"] == 2 def test_parse_node_count(self): result = _parse_response(self.SAMPLE_RESPONSE) assert len(result["cm_nodes"]) == 1 assert result["cm_nodes"][0]["name"] == "cucm-pub" def test_parse_device_fields(self): result = _parse_response(self.SAMPLE_RESPONSE) devices = result["cm_nodes"][0]["devices"] assert len(devices) == 2 first = devices[0] assert first["name"] == "SEP1234567890AB" assert first["status"] == "Registered" assert first["dir_number"] == "2001" assert first["description"] == "Patient Room 101" assert first["protocol"] == "SIP" # Nested IP extraction assert first["ip_address"] == "10.0.0.5" def test_parse_unregistered_device(self): result = _parse_response(self.SAMPLE_RESPONSE) second = result["cm_nodes"][0]["devices"][1] assert second["status"] == "UnRegistered" assert second["ip_address"] == "" # missing IP renders empty def test_parse_state_info_for_pagination(self): result = _parse_response(self.SAMPLE_RESPONSE) assert result["state_info"] == "" # last page def test_parse_soap_fault_raises(self): fault_response = """ Authentication failed """ with pytest.raises(RuntimeError, match="Authentication failed"): _parse_response(fault_response) class TestStatusValidation: def test_known_status_values(self): assert "Registered" in DEVICE_STATUS_VALUES assert "UnRegistered" in DEVICE_STATUS_VALUES assert "PartiallyRegistered" in DEVICE_STATUS_VALUES def test_select_cm_device_rejects_invalid_status(self): client = RisPortClient() # No env vars set, so _ensure_session would fail first; # but the validation should run BEFORE that on bad input. with pytest.raises(ValueError, match="status must be"): client.select_cm_device(status="not-a-real-status")