diff --git a/clients/python/src/zb_mom_ww_mxgateway/session.py b/clients/python/src/zb_mom_ww_mxgateway/session.py index 78f75aa..8a8d3bf 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/session.py +++ b/clients/python/src/zb_mom_ww_mxgateway/session.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence +from typing import Any from .auth import redact_secret from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success @@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None: error.raw_reply = _redact_reply(error.raw_reply, scrubbed) -def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply: +def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any: """Return a deep copy of *reply* with credential text scrubbed from diagnostics. Operates on a clone so the caller's original reply object is never mutated. Only the free-text diagnostic fields that can echo a client-supplied secret are scrubbed; the structured/enum fields the gateway itself sets are left as-is. + Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...): + each field is scrubbed only when the message type declares it. """ clone = type(reply)() clone.CopyFrom(reply) - if clone.protocol_status.message: + fields = clone.DESCRIPTOR.fields_by_name + if "protocol_status" in fields and clone.protocol_status.message: clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets) - if clone.diagnostic_message: + if "diagnostic_message" in fields and clone.diagnostic_message: clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets) - for status in clone.statuses: - if status.diagnostic_text: - status.diagnostic_text = redact_secret(status.diagnostic_text, secrets) + if "statuses" in fields: + for status in clone.statuses: + if status.diagnostic_text: + status.diagnostic_text = redact_secret(status.diagnostic_text, secrets) return clone diff --git a/clients/python/tests/test_alarms.py b/clients/python/tests/test_alarms.py index 4319139..d8a6e73 100644 --- a/clients/python/tests/test_alarms.py +++ b/clients/python/tests/test_alarms.py @@ -74,6 +74,40 @@ async def test_acknowledge_alarm_error_redacts_operator_password() -> None: assert "pw-secret" not in str(excinfo.value) +@pytest.mark.asyncio +async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None: + from zb_mom_ww_mxgateway.errors import MxGatewayError + + stub = FakeGatewayStub() + stub.acknowledge_alarm.replies = [ + pb.AcknowledgeAlarmReply( + protocol_status=pb.ProtocolStatus( + code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST, + message="rejected credential pw-secret", + ), + ), + ] + client = await GatewayClient.connect( + ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True), + stub=stub, + ) + + with pytest.raises(MxGatewayError) as excinfo: + await client.acknowledge_alarm( + pb.AcknowledgeAlarmRequest( + alarm_full_reference="Tank01.Level.HiHi", + operator_user="alice", + operator_password="pw-secret", + ), + ) + + assert "pw-secret" not in str(excinfo.value) + raw = excinfo.value.raw_reply + assert isinstance(raw, pb.AcknowledgeAlarmReply) + assert "pw-secret" not in raw.protocol_status.message + assert "pw-secret" not in str(raw) + + @pytest.mark.asyncio async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None: stub = FakeGatewayStub()