Skip to content

Commit d018821

Browse files
committed
fix(policy): CI green + real e2e test for the approval flow (#966)
- ruff format: tests/src/unit/test_settings_ui.py - test_save_and_roundtrip: account for save_policy version bump - test_serialized_shape_is_stable: include 'version' in expected keys - test_addon_save_returns_500_when_server_is_none: guard server._settings_secret_prefix assignment with None check (regression from #4's secret-prefix wiring) - tests/src/e2e/policy/test_approval_flow.py: real e2e exercising block -> approve -> re-call cycle with strict args-binding rejection on mutated args. Skip-stub replaced with real test driving the live middleware via mcp_client + /api/policy/* HTTP.
1 parent 4dfdfb4 commit d018821

4 files changed

Lines changed: 188 additions & 37 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3931,8 +3931,11 @@ def register_settings_routes(
39313931
# middleware reads this lazily via ``getattr(self,
39323932
# "_settings_secret_prefix", "")`` so the closure picks up the value
39333933
# set here, even though ``_apply_tool_security_policies`` ran in __init__
3934-
# before this function was called.
3935-
server._settings_secret_prefix = secret_prefix
3934+
# before this function was called. Skip when ``server is None`` (sidecar
3935+
# / unit-test shape) — the policy middleware isn't registered in that
3936+
# mode anyway, and a Mock-less ``None`` would raise AttributeError here.
3937+
if server is not None:
3938+
server._settings_secret_prefix = secret_prefix
39363939

39373940
if not is_addon and not secret_prefix:
39383941
logger.warning(
Lines changed: 177 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,185 @@
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.
1319
"""
1420

1521
from __future__ import annotations
1622

23+
import json
24+
from typing import Any
25+
from unittest.mock import AsyncMock, MagicMock
26+
1727
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
1836

37+
from ..utilities.assertions import parse_mcp_result, tool_error_to_result
1938

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.
3948
"""
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)

tests/src/unit/policy/test_persistence.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ def test_save_and_roundtrip(tmp_path: Path):
2424
)
2525
save_policy(tmp_path, original)
2626
loaded = load_policy(tmp_path)
27-
assert loaded == original
27+
# save_policy bumps version on write (optimistic concurrency contract);
28+
# compare every other field, then version separately.
29+
assert loaded.version == original.version + 1
30+
assert loaded.model_copy(update={"version": 0}) == original
2831

2932

3033
def test_save_writes_atomically(tmp_path: Path):
@@ -49,4 +52,5 @@ def test_serialized_shape_is_stable(tmp_path: Path):
4952
"wait_seconds",
5053
"approval_ttl_minutes",
5154
"rules",
55+
"version",
5256
}

tests/src/unit/test_settings_ui.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,7 @@ def test_default_pinned_tool_can_be_unpinned_via_enabled_state(self):
206206
# Server.py's filter: pinned = [n for n in DEFAULT_PINNED_TOOLS
207207
# if n not in result.enabled_names]
208208
effective_pinned = [
209-
name
210-
for name in DEFAULT_PINNED_TOOLS
211-
if name not in result.enabled_names
209+
name for name in DEFAULT_PINNED_TOOLS if name not in result.enabled_names
212210
]
213211
assert "ha_config_get_automation" not in effective_pinned
214212
# Tools NOT in the config keep their default pinning.

0 commit comments

Comments
 (0)