|
1 | | -"""End-to-end test for tool security policies middleware (#966). |
2 | | -
|
3 | | -Currently a placeholder — full e2e requires a testcontainers Home Assistant |
4 | | -instance plus an MCP client driving the middleware over HTTP. Once the e2e |
5 | | -suite gains a fixture for the tool security policies pipeline (settings UI |
6 | | -routes mounted + middleware registered against the same FastMCP), this test |
7 | | -should be filled in. The integration coverage at |
8 | | -``tests/src/unit/policy/test_middleware.py`` (test_recall_after_approval_executes, |
9 | | -test_recall_with_mutated_args_creates_new_pending, etc.) exercises the in-process |
10 | | -block/approve/recall loop in the meantime. |
11 | | -
|
12 | | -Run via ``cd tests && uv run pytest src/e2e/policy/``. |
| 1 | +"""Real e2e test for the tool security policies middleware (#966). |
| 2 | +
|
| 3 | +Drives the FULL block → approve → re-call loop against a live |
| 4 | +testcontainer HA + ha-mcp server with ``ENABLE_TOOL_SECURITY_POLICIES=true``, |
| 5 | +using a function-scoped policy-enabled server fixture distinct from the |
| 6 | +session-scoped ``mcp_client`` (which boots without policies). |
| 7 | +
|
| 8 | +The /api/policy/* HTTP routes are mounted on the same FastMCP Starlette |
| 9 | +app as the MCP endpoint; the in-memory ``mcp_client`` transport bypasses |
| 10 | +that app, so we drive the policy handlers (returned by |
| 11 | +``build_policy_handlers``) via the same async ``Request`` -> ``JSONResponse`` |
| 12 | +contract the HTTP routes use. This still exercises the production handler |
| 13 | +factory + ``ApprovalQueue`` + persistence path end-to-end — only the |
| 14 | +Starlette routing layer is short-circuited. The MCP transport / tool |
| 15 | +dispatch / middleware pipeline are exercised exactly as a real client |
| 16 | +would see them. |
| 17 | +
|
| 18 | +Cannot run on Termux (no Docker for testcontainers); CI-only verification. |
13 | 19 | """ |
14 | 20 |
|
15 | 21 | from __future__ import annotations |
16 | 22 |
|
| 23 | +import json |
| 24 | +from typing import Any |
| 25 | +from unittest.mock import AsyncMock, MagicMock |
| 26 | + |
17 | 27 | import pytest |
| 28 | +from fastmcp import Client |
| 29 | +from fastmcp.exceptions import ToolError |
| 30 | +from test_constants import TEST_TOKEN |
| 31 | + |
| 32 | +from ha_mcp.client.rest_client import HomeAssistantClient |
| 33 | +from ha_mcp.policy.handlers import build_policy_handlers |
| 34 | +from ha_mcp.server import HomeAssistantSmartMCPServer |
| 35 | +from ha_mcp.utils.data_paths import get_data_dir |
18 | 36 |
|
| 37 | +from ..utilities.assertions import parse_mcp_result, tool_error_to_result |
19 | 38 |
|
20 | | -@pytest.mark.skip(reason="e2e scaffold — requires HA testcontainer + policy fixture") |
21 | | -def test_blocked_call_then_approve_then_recall() -> None: |
22 | | - """User policy gates ``ha_call_service`` for ``domain=lock``. |
23 | | -
|
24 | | - Expected flow once implemented: |
25 | | -
|
26 | | - 1. ``PUT /api/policy/config`` enabling a rule on ``ha_call_service`` |
27 | | - with predicate ``args.service_data.entity_id startswith "lock."`` |
28 | | - (or ``args.domain == "lock"`` depending on the canonical schema). |
29 | | - 2. Call ``ha_call_service`` with a matching ``entity_id`` — expect a |
30 | | - ``ToolError`` whose payload carries error code |
31 | | - ``USER_APPROVAL_REQUIRED`` and an approval ``token``. |
32 | | - 3. ``POST /api/policy/approve`` with that token. |
33 | | - 4. Re-call ``ha_call_service`` with the same arguments — expect |
34 | | - success (the remember-cache short-circuits the gate for the |
35 | | - configured window, OR the queued approval is consumed once). |
36 | | - 5. Re-call with *different* arguments — expect |
37 | | - ``USER_APPROVAL_REQUIRED`` again, confirming strict args binding |
38 | | - (the gate does not blanket-approve every future call to the tool). |
| 39 | + |
| 40 | +async def _expect_blocked(client: Client, args: dict[str, Any]) -> dict[str, Any]: |
| 41 | + """Call ``ha_call_service`` and return the parsed USER_APPROVAL_REQUIRED body. |
| 42 | +
|
| 43 | + FastMCP clients normalize middleware-raised ``ToolError`` to either |
| 44 | + a raised ``ToolError`` (older transport behavior) or a result with |
| 45 | + ``isError=True`` carrying the JSON body in ``content[0].text`` (newer |
| 46 | + transport). Accept both so the test isn't pinned to a specific |
| 47 | + FastMCP version. |
39 | 48 | """ |
| 49 | + try: |
| 50 | + result = await client.call_tool("ha_call_service", args) |
| 51 | + except ToolError as exc: |
| 52 | + body = tool_error_to_result(exc) |
| 53 | + else: |
| 54 | + body = parse_mcp_result(result) |
| 55 | + assert body.get("error", {}).get("code") == "USER_APPROVAL_REQUIRED", body |
| 56 | + return body |
| 57 | + |
| 58 | + |
| 59 | +def _make_request(body: dict[str, Any] | None = None) -> MagicMock: |
| 60 | + """Build a minimal Starlette ``Request`` mock for direct handler calls. |
| 61 | +
|
| 62 | + The /api/policy/* handlers only need ``await request.json()``; mock just |
| 63 | + that surface rather than wiring a full ASGI scope. |
| 64 | + """ |
| 65 | + request = MagicMock() |
| 66 | + request.json = AsyncMock(return_value=body or {}) |
| 67 | + return request |
| 68 | + |
| 69 | + |
| 70 | +@pytest.fixture |
| 71 | +async def policy_enabled_mcp(ha_container_with_fresh_config, monkeypatch, tmp_path): |
| 72 | + """Spin up a fresh policy-enabled MCP server bound to the testcontainer HA. |
| 73 | +
|
| 74 | + Function-scoped so each test gets a clean ``ApprovalQueue`` and an |
| 75 | + isolated ``tool_policy.json`` (no cross-test bleed via the lru-cached |
| 76 | + ``get_data_dir``). The session-scoped ``mcp_server`` / ``mcp_client`` |
| 77 | + fixtures boot without ``ENABLE_TOOL_SECURITY_POLICIES`` so they can't |
| 78 | + be reused here. |
| 79 | +
|
| 80 | + Yields ``(client, server, policy_handlers)``: |
| 81 | + * ``client`` — in-memory ``fastmcp.Client`` bound to the policy-enabled MCP |
| 82 | + * ``server`` — the underlying ``HomeAssistantSmartMCPServer`` (exposes |
| 83 | + ``approval_queue``) |
| 84 | + * ``policy_handlers`` — dict of policy_get_config / policy_put_config / |
| 85 | + policy_post_approve / etc. closures, equivalent to what the HTTP |
| 86 | + routes mount. |
| 87 | + """ |
| 88 | + container_info = ha_container_with_fresh_config |
| 89 | + if container_info.get("backend") == "haos_inaddon": |
| 90 | + pytest.skip( |
| 91 | + "Inaddon backend uses the addon's own MCP endpoint; this test " |
| 92 | + "needs an in-process server with ENABLE_TOOL_SECURITY_POLICIES=true." |
| 93 | + ) |
| 94 | + |
| 95 | + monkeypatch.setenv("ENABLE_TOOL_SECURITY_POLICIES", "true") |
| 96 | + monkeypatch.setenv("HA_MCP_CONFIG_DIR", str(tmp_path)) |
| 97 | + get_data_dir.cache_clear() |
| 98 | + |
| 99 | + # Reset cached settings so the new server picks up the env var. |
| 100 | + import ha_mcp.config |
| 101 | + |
| 102 | + monkeypatch.setattr(ha_mcp.config, "_settings", None) |
| 103 | + |
| 104 | + base_url = container_info["base_url"] |
| 105 | + token = container_info.get("token", TEST_TOKEN) |
| 106 | + ha_client = HomeAssistantClient(base_url=base_url, token=token) |
| 107 | + |
| 108 | + server = HomeAssistantSmartMCPServer(client=ha_client) |
| 109 | + assert getattr(server, "approval_queue", None) is not None, ( |
| 110 | + "ENABLE_TOOL_SECURITY_POLICIES=true did not register an ApprovalQueue; " |
| 111 | + "verify _apply_tool_security_policies ran successfully." |
| 112 | + ) |
| 113 | + |
| 114 | + handlers = build_policy_handlers( |
| 115 | + data_dir=tmp_path, |
| 116 | + queue=server.approval_queue, |
| 117 | + ) |
| 118 | + |
| 119 | + client = Client(server.mcp) |
| 120 | + async with client: |
| 121 | + yield client, server, handlers |
| 122 | + |
| 123 | + await ha_client.close() |
| 124 | + get_data_dir.cache_clear() |
| 125 | + |
| 126 | + |
| 127 | +@pytest.mark.asyncio |
| 128 | +async def test_blocked_call_then_approve_then_recall(policy_enabled_mcp): |
| 129 | + """Block, approve, re-call succeeds; mutated args re-block (#966). |
| 130 | +
|
| 131 | + Exercises the full middleware loop through the real MCP transport: |
| 132 | + 1. ``PUT /api/policy/config`` enables a rule on ``ha_call_service`` |
| 133 | + when ``args.domain == "light"``. |
| 134 | + 2. First ``ha_call_service`` raises ``ToolError`` carrying |
| 135 | + ``USER_APPROVAL_REQUIRED`` + an approval token. |
| 136 | + 3. ``POST /api/policy/approve`` consumes the token. |
| 137 | + 4. Re-call with SAME args succeeds (queue lookup hits approved entry). |
| 138 | + 5. Re-call with DIFFERENT args re-blocks (strict args-hash binding; |
| 139 | + approval does not blanket-permit future calls). |
| 140 | + """ |
| 141 | + client, server, handlers = policy_enabled_mcp |
| 142 | + |
| 143 | + # 1. Install a rule that gates light service calls. |
| 144 | + current_resp = await handlers["policy_get_config"](_make_request()) |
| 145 | + current = json.loads(current_resp.body) |
| 146 | + new_policy = { |
| 147 | + "enabled": True, |
| 148 | + "wait_seconds": 5, |
| 149 | + "approval_ttl_minutes": 5, |
| 150 | + "rules": [ |
| 151 | + { |
| 152 | + "tool_name": "ha_call_service", |
| 153 | + "when": [{"path": "args.domain", "op": "eq", "value": "light"}], |
| 154 | + "remember_minutes": 0, |
| 155 | + } |
| 156 | + ], |
| 157 | + "version": current["version"], |
| 158 | + } |
| 159 | + put_resp = await handlers["policy_put_config"](_make_request(new_policy)) |
| 160 | + assert put_resp.status_code == 200, put_resp.body |
| 161 | + |
| 162 | + # 2. First call: middleware gates → USER_APPROVAL_REQUIRED. |
| 163 | + args = {"domain": "light", "service": "turn_on", "entity_id": "light.bed_light"} |
| 164 | + await _expect_blocked(client, args) |
| 165 | + pending = server.approval_queue.list_pending() |
| 166 | + assert len(pending) == 1, f"expected exactly one pending entry, got {pending!r}" |
| 167 | + token = pending[0].token |
| 168 | + |
| 169 | + # 3. Approve via the same handler the HTTP route would call. |
| 170 | + approve_resp = await handlers["policy_post_approve"]( |
| 171 | + _make_request({"token": token}) |
| 172 | + ) |
| 173 | + assert approve_resp.status_code == 200, approve_resp.body |
| 174 | + |
| 175 | + # 4. Re-call with SAME args: middleware sees approved entry → proceeds. |
| 176 | + result = await client.call_tool("ha_call_service", args) |
| 177 | + assert not result.is_error, result |
| 178 | + |
| 179 | + # 5. Re-call with DIFFERENT args: strict args-hash binding → new gate. |
| 180 | + other_args = { |
| 181 | + "domain": "light", |
| 182 | + "service": "turn_off", |
| 183 | + "entity_id": "light.bed_light", |
| 184 | + } |
| 185 | + await _expect_blocked(client, other_args) |
0 commit comments