"""Tests for elicitation (human-in-the-loop confirmation) on mutation tools.""" import os from unittest.mock import AsyncMock, patch import pytest from fastmcp.exceptions import ToolError from fastmcp.server.elicitation import ( AcceptedElicitation, CancelledElicitation, DeclinedElicitation, ) from mcdbus._interaction import _confirm_or_abort class TestConfirmOrAbort: """Unit tests for the _confirm_or_abort helper.""" async def test_accepted_proceeds(self): ctx = AsyncMock() ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="confirm")) # Should return without raising await _confirm_or_abort(ctx, "Test message", "test_op") async def test_declined_raises_tool_error(self): ctx = AsyncMock() ctx.elicit = AsyncMock(return_value=DeclinedElicitation()) with pytest.raises(ToolError, match="cancelled by user"): await _confirm_or_abort(ctx, "Test message", "test_op") async def test_accepted_wrong_key_raises(self): ctx = AsyncMock() ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="deny")) with pytest.raises(ToolError, match="cancelled by user"): await _confirm_or_abort(ctx, "Test message", "test_op") async def test_cancelled_proceeds_by_default(self): ctx = AsyncMock() ctx.elicit = AsyncMock(return_value=CancelledElicitation()) # Client doesn't support elicitation — should proceed silently await _confirm_or_abort(ctx, "Test message", "test_op") async def test_cancelled_hard_fails_when_required(self): ctx = AsyncMock() ctx.elicit = AsyncMock(return_value=CancelledElicitation()) with patch.dict(os.environ, {"MCDBUS_REQUIRE_ELICITATION": "1"}): with pytest.raises(ToolError, match="Elicitation required"): await _confirm_or_abort(ctx, "Test message", "test_op") async def test_elicit_exception_treated_as_cancelled(self): """When ctx.elicit() throws (client lacks protocol support), proceed.""" ctx = AsyncMock() ctx.elicit = AsyncMock(side_effect=Exception("Method not found")) # Should not raise — exception is treated as CancelledElicitation await _confirm_or_abort(ctx, "Test message", "test_op") async def test_elicit_exception_hard_fails_when_required(self): """When ctx.elicit() throws and MCDBUS_REQUIRE_ELICITATION is set, fail.""" ctx = AsyncMock() ctx.elicit = AsyncMock(side_effect=Exception("Method not found")) with patch.dict(os.environ, {"MCDBUS_REQUIRE_ELICITATION": "1"}): with pytest.raises(ToolError, match="Elicitation required"): await _confirm_or_abort(ctx, "Test message", "test_op") class TestCallMethodElicitation: """Verify call_method triggers elicitation on system bus but not session bus.""" async def test_session_bus_no_elicitation(self, client): """Session bus calls should not trigger elicitation.""" # Ping on session bus — should succeed without any elicitation 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() class TestSetPropertyElicitation: """Verify set_property always triggers elicitation.""" async def test_invalid_property_still_validates_first(self, client): """Validation errors should fire before elicitation.""" with pytest.raises(ToolError, match="Invalid D-Bus"): await client.call_tool("set_property", { "bus": "session", "service": "not-valid", "object_path": "/foo", "interface": "org.test.Iface", "property_name": "Foo", "value": '"bar"', "signature": "s", })