"""Tests for _ReadOnlyServiceProxy: defense-in-depth allowlist for AXL methods. The proxy wraps the zeep service object so any SOAP method outside {getCCMVersion, executeSQLQuery} raises ReadOnlyViolation at attribute lookup, before zeep serializes an envelope. This is a guard against future contributors accidentally calling write methods like addRoutePartition(). """ from unittest.mock import MagicMock import pytest from mcaxl.client import ( ReadOnlyViolation, _ALLOWED_AXL_METHODS, _ReadOnlyServiceProxy, ) class TestAllowlistEnforcement: def test_allowed_method_dispatches_through(self): # Both methods we currently use must pass through the proxy. inner = MagicMock() inner.getCCMVersion.return_value = {"version": "15.0(1)"} inner.executeSQLQuery.return_value = {"rows": []} proxy = _ReadOnlyServiceProxy(inner) assert proxy.getCCMVersion() == {"version": "15.0(1)"} assert proxy.executeSQLQuery(sql="SELECT 1") == {"rows": []} inner.getCCMVersion.assert_called_once() inner.executeSQLQuery.assert_called_once_with(sql="SELECT 1") @pytest.mark.parametrize( "method_name", [ "addRoutePartition", "updatePhone", "removeUser", "applyPhone", "resetPhone", "restartPhone", "executeSQLUpdate", # the AXL write counterpart "doDeviceLogin", "wipePhone", ], ) def test_disallowed_method_raises(self, method_name): inner = MagicMock() proxy = _ReadOnlyServiceProxy(inner) with pytest.raises(ReadOnlyViolation, match=method_name): getattr(proxy, method_name) # The inner service must NOT have been touched at all — refusal # happens before any SOAP serialization. assert not getattr(inner, method_name).called def test_allowlist_is_exactly_what_we_advertise(self): # If this set ever grows, that's a deliberate decision and the # test should be updated alongside the change. Catching unintended # widening of the read-only surface is the point. assert _ALLOWED_AXL_METHODS == frozenset( {"getCCMVersion", "executeSQLQuery"} ) class TestAttributePassthrough: def test_dunder_attributes_pass_through(self): # Zeep introspects services via dunder attributes (__class__, # __dict__, etc.). The proxy must not break those. inner = MagicMock() inner.__class__ = MagicMock proxy = _ReadOnlyServiceProxy(inner) # Reading the class doesn't raise _ = proxy.__class__ def test_underscore_prefixed_attributes_pass_through(self): # zeep service internals like `_binding_options`, `_operations` # are accessed by name. We don't want to gate those because they # don't dispatch SOAP — they read metadata. inner = MagicMock() inner._binding_options = {"address": "https://cucm/axl/"} inner._operations = ["getCCMVersion", "executeSQLQuery", "addPhone"] proxy = _ReadOnlyServiceProxy(inner) assert proxy._binding_options == {"address": "https://cucm/axl/"} assert "addPhone" in proxy._operations # introspection, not dispatch