mcdbus/tests/test_tools.py
Ryan Malloy 5fa1eb36ef Hamilton remediation: validation, ToolError, elicitation, permission docs
Three-pillar fix from Hamilton review:

Code quality — validate_signature() for D-Bus spec compliance,
MCDBUS_TIMEOUT env var, replace 13 error-as-success returns with
ToolError, monotonic clock deadline on tree walks, sanitize D-Bus
error messages, fix resource connection leak via module-level
BusManager, hasattr guards in conftest.

Elicitation — ctx.elicit() confirmation for system bus call_method
and all set_property calls, graceful degradation when client lacks
elicitation support, MCDBUS_REQUIRE_ELICITATION for hard-fail mode.

Permission docs — four-layer guide (systemd sandboxing, dbus-broker
policy, polkit rules, xdg-dbus-proxy) with ready-to-deploy example
configs validated against xmllint, bash -n, and systemd-analyze.
2026-03-06 11:54:31 -07:00

293 lines
10 KiB
Python

"""Tool-level tests — every MCP tool exercised through Client(mcp) in-memory transport."""
import json
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
# ---------------------------------------------------------------------------
# Discovery tools
# ---------------------------------------------------------------------------
class TestListServices:
async def test_returns_services(self, client: Client):
result = await client.call_tool("list_services", {"bus": "session"})
text = result.content[0].text
assert "org.freedesktop.DBus" in text
assert "D-Bus session bus" in text
async def test_include_unique(self, client: Client):
result = await client.call_tool(
"list_services", {"bus": "session", "include_unique": True}
)
text = result.content[0].text
assert ":1." in text
class TestIntrospect:
async def test_dbus_daemon(self, client: Client):
result = await client.call_tool("introspect", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
})
text = result.content[0].text
assert "org.freedesktop.DBus" in text
assert "ListNames" in text
async def test_invalid_path(self, client: Client):
with pytest.raises(ToolError, match="Invalid D-Bus object path"):
await client.call_tool("introspect", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "not-a-path",
})
class TestListObjects:
async def test_dbus_tree(self, client: Client):
result = await client.call_tool("list_objects", {
"bus": "session",
"service": "org.freedesktop.DBus",
})
text = result.content[0].text
assert "Object tree" in text
assert "/org/freedesktop/DBus" in text
# ---------------------------------------------------------------------------
# Interaction tools
# ---------------------------------------------------------------------------
class TestCallMethod:
async def test_ping_void(self, client: Client):
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus.Peer",
"method": "Ping",
})
text = result.content[0].text
assert "void" in text.lower()
async def test_get_id_returns_hex(self, client: Client):
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
})
text = result.content[0].text
# GetId returns a hex string, JSON-encoded with quotes
bus_id = json.loads(text)
assert isinstance(bus_id, str)
assert len(bus_id) > 10
async def test_bad_method_raises_tool_error(self, client: Client):
with pytest.raises(ToolError, match="D-Bus error"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "NoSuchMethod",
})
class TestGetProperty:
async def test_read_property(self, client: Client):
# Read the Features property from the DBus daemon
result = await client.call_tool("get_property", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"property_name": "Features",
})
text = result.content[0].text
# Features returns an array of strings
features = json.loads(text)
assert isinstance(features, list)
class TestGetAllProperties:
async def test_dbus_daemon(self, client: Client):
result = await client.call_tool("get_all_properties", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
})
text = result.content[0].text
assert "Properties of" in text
assert "Features" in text
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestSignatureValidation:
async def test_invalid_signature_rejected(self, client: Client):
with pytest.raises(ToolError, match="Invalid D-Bus signature"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "INVALID!@#",
"args": '["x"]',
})
async def test_signature_too_long(self, client: Client):
with pytest.raises(ToolError, match="Signature too long"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "s" * 256,
"args": '["x"]',
})
async def test_malformed_signature_rejected(self, client: Client):
with pytest.raises(ToolError, match="Malformed D-Bus signature"):
await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus",
"method": "GetId",
"signature": "((",
"args": '["x"]',
})
# ---------------------------------------------------------------------------
# Shortcut tools — existing
# ---------------------------------------------------------------------------
class TestSendNotification:
async def test_sends(self, client: Client):
result = await client.call_tool("send_notification", {
"summary": "mcdbus test",
"body": "automated test notification",
"timeout": 1000,
})
text = result.content[0].text
assert "Notification sent" in text
assert "id:" in text
class TestListSystemdUnits:
async def test_returns_table(self, client: Client):
result = await client.call_tool("list_systemd_units", {})
text = result.content[0].text
assert "Systemd units" in text
assert "| Unit |" in text
async def test_pattern_filter(self, client: Client):
result = await client.call_tool(
"list_systemd_units", {"pattern": "dbus*"}
)
text = result.content[0].text
assert "dbus" in text.lower()
class TestMediaPlayerControl:
async def test_no_players_graceful(self, client: Client):
# With no explicit player, it auto-discovers; if none found, returns message
result = await client.call_tool(
"media_player_control", {"action": "play"}
)
text = result.content[0].text
# Either "No MPRIS media players" or a player response — both are valid
assert "Player:" in text or "No MPRIS" in text
async def test_bad_action(self, client: Client):
result = await client.call_tool(
"media_player_control", {"action": "invalid_action"}
)
text = result.content[0].text
# auto-discovers player first; if none found, "No MPRIS" message
# if player found, "Unknown action" message
assert "Unknown action" in text or "No MPRIS" in text
# ---------------------------------------------------------------------------
# Shortcut tools — new (service may or may not be available)
# ---------------------------------------------------------------------------
class TestNetworkStatus:
async def test_returns_status(self, client: Client):
try:
result = await client.call_tool("network_status", {})
except ToolError:
return # NetworkManager not available on this system
text = result.content[0].text
assert "Network Status" in text
async def test_has_state(self, client: Client):
try:
result = await client.call_tool("network_status", {})
except ToolError:
return # NetworkManager not available
text = result.content[0].text
assert "State:" in text
class TestBatteryStatus:
async def test_returns_result(self, client: Client):
try:
result = await client.call_tool("battery_status", {})
except ToolError:
return # UPower not available on this system
text = result.content[0].text
assert (
"Battery Status" in text
or "No batteries found" in text
or "No power devices" in text
)
class TestBluetoothDevices:
async def test_returns_result(self, client: Client):
try:
result = await client.call_tool("bluetooth_devices", {})
except ToolError:
return # bluez not available on this system
text = result.content[0].text
assert (
"Bluetooth Devices" in text
or "No Bluetooth devices" in text
or "No Bluetooth objects" in text
)
class TestKwinWindows:
async def test_returns_windows(self, client: Client):
try:
result = await client.call_tool("kwin_windows", {})
except ToolError:
return # KWin not available on this system
text = result.content[0].text
assert (
"KWin Windows" in text
or "No windows found" in text
or "No open windows" in text
)
async def test_has_table(self, client: Client):
try:
result = await client.call_tool("kwin_windows", {})
except ToolError:
return # KWin not available
text = result.content[0].text
if "KWin Windows" in text:
assert "| Window |" in text