From f8e3adbfb15183944ca017e13e6901f54c89f09a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 8 Sep 2026 10:01:04 +0530 Subject: [PATCH] fix: guard against None action in ZPA isolation policy rule updates update_isolation_rule and update_isolation_rule_v2 both declare action as optional (action: str = None) but build their payload with an unguarded action.upper(). A partial update that omits action raises AttributeError: 'NoneType' object has no attribute 'upper' before any request is made, so neither method can be used for a partial update. Both now use the guarded form the sibling update methods in this module already use, e.g. update_access_rule: "action": action.upper() if action else None None is what the surrounding keys (name, description, zpnIsolationProfileId) already resolve to when omitted, so the payload stays internally consistent and nothing changes when action is supplied. The remaining unguarded action.upper() sites in this module take action as a required positional parameter, so omitting it raises TypeError rather than AttributeError; those are left alone. --- tests/unit/test_zpa_policies.py | 50 +++++++++++++++++++++++++++++++++ zscaler/zpa/policies.py | 4 +-- 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_zpa_policies.py diff --git a/tests/unit/test_zpa_policies.py b/tests/unit/test_zpa_policies.py new file mode 100644 index 00000000..b82afe7b --- /dev/null +++ b/tests/unit/test_zpa_policies.py @@ -0,0 +1,50 @@ +""" +Unit tests for ZPA Policy Set Controller rule updates. + +Tests that a partial update which omits the optional ``action`` keyword +is still sent to the API instead of raising ``AttributeError``. +""" + +from unittest.mock import Mock + +import pytest + +from zscaler.zpa.policies import PolicySetControllerAPI + +CUSTOMER_ID = "72058304855090128" +POLICY_SET_ID = "72058304855090130" +RULE_ID = "72058304855090129" + + +def build_api(): + """Return a PolicySetControllerAPI and the request executor it was given.""" + policy_set = Mock() + policy_set.get_body.return_value = {"id": POLICY_SET_ID} + + mock_executor = Mock() + mock_executor.create_request.return_value = ({}, None) + # First execute() resolves the policy set, the second performs the rule update. + mock_executor.execute.side_effect = [(policy_set, None), (None, None)] + + return PolicySetControllerAPI(mock_executor, {"client": {"customerId": CUSTOMER_ID}}), mock_executor + + +class TestUpdateIsolationRuleWithoutAction: + """Test update_isolation_rule and update_isolation_rule_v2 partial updates.""" + + @pytest.mark.parametrize("method_name", ["update_isolation_rule", "update_isolation_rule_v2"]) + def test_update_without_action_sends_request(self, method_name): + """Test omitting the optional action keyword still issues the update request.""" + # Setup + api, mock_executor = build_api() + + # Test + rule, _, error = getattr(api, method_name)(rule_id=RULE_ID, description="partial update") + + # Assertions + assert error is None + assert rule.id == RULE_ID + + payload = mock_executor.create_request.call_args.kwargs["body"] + assert payload["action"] is None + assert payload["description"] == "partial update" diff --git a/zscaler/zpa/policies.py b/zscaler/zpa/policies.py index 64b9c5ed..0a0da29b 100644 --- a/zscaler/zpa/policies.py +++ b/zscaler/zpa/policies.py @@ -1305,7 +1305,7 @@ def update_isolation_rule( "name": name, "description": kwargs.get("description"), "rule_order": kwargs.get("rule_order"), - "action": action.upper(), + "action": action.upper() if action else None, "zpnIsolationProfileId": zpn_isolation_profile_id, "conditions": self._create_conditions_v1(kwargs.pop("conditions", [])), } @@ -2520,7 +2520,7 @@ def update_isolation_rule_v2( "name": name, "description": kwargs.get("description"), "rule_order": kwargs.get("rule_order"), - "action": action.upper(), + "action": action.upper() if action else None, "zpnIsolationProfileId": zpn_isolation_profile_id, "conditions": self._create_conditions_v2(kwargs.pop("conditions", [])), }