When MCP elicitation is unavailable (most clients), fall back to org.freedesktop.Notifications with Approve/Deny action buttons. Opt-in via MCDBUS_NOTIFY_CONFIRM=1. Silence (timeout) is denial. Fixes signal race where NotificationClosed stomped ActionInvoked result in the same event loop iteration.
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""Tests for D-Bus notification confirmation fallback."""
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from dbus_fast import MessageType
|
|
|
|
from mcdbus._notify_confirm import notify_confirm
|
|
|
|
NID = 42
|
|
IFACE = "org.freedesktop.Notifications"
|
|
|
|
|
|
def _make_signal(interface, member, body):
|
|
"""Create a mock D-Bus signal message."""
|
|
msg = MagicMock()
|
|
msg.message_type = MessageType.SIGNAL
|
|
msg.interface = interface
|
|
msg.member = member
|
|
msg.body = body
|
|
return msg
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_bus():
|
|
"""Mock MessageBus with handler registration tracking."""
|
|
bus = MagicMock()
|
|
bus._handlers = []
|
|
|
|
def _add(h):
|
|
bus._handlers.append(h)
|
|
|
|
def _remove(h):
|
|
if h in bus._handlers:
|
|
bus._handlers.remove(h)
|
|
|
|
bus.add_message_handler = MagicMock(side_effect=_add)
|
|
bus.remove_message_handler = MagicMock(side_effect=_remove)
|
|
return bus
|
|
|
|
|
|
def _fire_signal(mock_bus, interface, member, body, delay=0.01):
|
|
"""Schedule a signal delivery after a brief delay."""
|
|
async def _deliver():
|
|
await asyncio.sleep(delay)
|
|
msg = _make_signal(interface, member, body)
|
|
for handler in list(mock_bus._handlers):
|
|
handler(msg)
|
|
asyncio.create_task(_deliver())
|
|
|
|
|
|
class TestNotifyConfirm:
|
|
async def test_approve_returns_true(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
|
assert result is True
|
|
|
|
async def test_deny_returns_false(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"])
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
|
assert result is False
|
|
|
|
async def test_dismissed_returns_false(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
_fire_signal(mock_bus, IFACE, "NotificationClosed", [NID, 2])
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
result = await notify_confirm(mock_bus, "Test", "Confirm?")
|
|
assert result is False
|
|
|
|
async def test_timeout_returns_false(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=0.05)
|
|
assert result is False
|
|
|
|
async def test_service_unavailable_raises(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
raise RuntimeError("org.freedesktop.DBus.Error.ServiceUnknown")
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
with pytest.raises(RuntimeError, match="ServiceUnknown"):
|
|
await notify_confirm(mock_bus, "Test", "Confirm?")
|
|
|
|
async def test_handler_ignores_wrong_nid(self, mock_bus):
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
if member == "Notify":
|
|
# Wrong nid first, correct nid after
|
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [999, "approve"], delay=0.01)
|
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"], delay=0.03)
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=1.0)
|
|
# Wrong nid was ignored; correct nid with "deny" triggered
|
|
assert result is False
|
|
|
|
async def test_cleanup_called(self, mock_bus):
|
|
members_called = []
|
|
|
|
async def mock_call(bus, destination, path, interface, member,
|
|
signature="", body=None, timeout=30.0):
|
|
members_called.append(member)
|
|
if member == "Notify":
|
|
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
|
|
return [NID]
|
|
return None
|
|
|
|
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
|
|
await notify_confirm(mock_bus, "Test", "Confirm?")
|
|
|
|
mock_bus.remove_message_handler.assert_called_once()
|
|
assert "CloseNotification" in members_called
|
|
assert members_called.count("RemoveMatch") == 2
|