mcaxl/tests/test_readonly_proxy.py
Ryan Malloy 639d706200 client/risport: add read-only allowlist proxies (defense-in-depth)
Today, mcaxl is read-only against CUCM by *absence* — the tools
never call write methods. But absence isn't enforced: a future
contributor adding a tool could write
self._service.addRoutePartition(...) and zeep would happily
dispatch it. There's no positive guard.

Two new chokepoints close that gap:

AXL side — _ReadOnlyServiceProxy wraps the zeep service object.
__getattr__ refuses any method outside _ALLOWED_AXL_METHODS
(currently {getCCMVersion, executeSQLQuery}) with a new
ReadOnlyViolation exception, raised at attribute lookup BEFORE
zeep serializes a SOAP envelope. Underscore-prefixed and dunder
attributes pass through (zeep introspects via _binding_options,
__class__, etc., and those don't dispatch SOAP).

RisPort side — RisPort70 envelopes are hand-rolled, so the proxy
pattern doesn't apply directly. The equivalent chokepoint lives in
the envelope builders: _check_operation_allowed(name) is the first
line of every builder, and _ALLOWED_RISPORT_OPERATIONS is the
allowlist (currently {selectCmDevice}).

Operators can verify the proxy is active via the health tool —
connection_status() now reports read_only_proxy: true and
allowed_axl_methods: [...].

Tests:
- new tests/test_readonly_proxy.py (13 tests):
  * allowed methods dispatch through to inner service
  * 9 parameterized refusals (addRoutePartition, updatePhone,
    removeUser, applyPhone, resetPhone, restartPhone,
    executeSQLUpdate, doDeviceLogin, wipePhone)
  * allowlist drift detection (set must be exactly what we
    advertise — accidental widening fails red)
  * dunder + underscore-prefixed passthrough
- tests/test_risport.py: +TestReadOnlyAllowlist (7 tests):
  * selectCmDevice passes _check_operation_allowed
  * 6 parameterized refusals (addCmDevice, removeCmDevice,
    resetDevice, restartDevice, applyCmDevice, executeSQLUpdate)
  * allowlist drift detection

182 tests pass total (was 161; +13 proxy + 7 risport + 1 allowlist
drift catch).
2026-04-29 06:38:41 -06:00

89 lines
3.2 KiB
Python

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