Files
javis/tests/test_homey_mcp.py
2026-10-04 15:31:44 +02:00

159 lines
6.5 KiB
Python

"""Tests for homey-mcp (run with homey-mcp/.venv: needs mcp 1.x FastMCP).
homey-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_homey_mcp.py -q
"""
from __future__ import annotations
import asyncio
import types
import pytest
pytest.importorskip("mcp.server.fastmcp")
pytest.importorskip("homey_mcp")
import httpx # noqa: E402
from starlette.testclient import TestClient # noqa: E402
from homey_mcp import server as srv # noqa: E402
from homey_mcp.config import Settings # noqa: E402
from homey_mcp.homey_client import HomeyClient, event_entry # noqa: E402
ZONES = {
"z1": {"id": "z1", "name": "Wohnzimmer Ecke"},
"z2": {"id": "z2", "name": "Wohnzimmer"},
"z3": {"id": "z3", "name": "Esszimmer"},
}
DEVICES = {
"d1": {"id": "d1", "name": "Deckenlicht", "zone": "z3", "class": "light",
"capabilities": ["onoff", "dim"]},
"d2": {"id": "d2", "name": "Licht Fenster", "zone": "z2", "class": "light",
"capabilities": ["onoff"]},
"d3": {"id": "d3", "name": "Heizung", "zone": "z3", "class": "thermostat",
"capabilities": ["target_temperature"]},
}
def _client(handler) -> tuple[HomeyClient, list[httpx.Request]]:
seen: list[httpx.Request] = []
def _record(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
client = HomeyClient(Settings(host="homey.local", token="t", realtime=False))
client._http = httpx.AsyncClient(base_url=client.settings.base_url,
transport=httpx.MockTransport(_record))
return client, seen
def _ctx(client: HomeyClient):
return types.SimpleNamespace(
request_context=types.SimpleNamespace(lifespan_context=srv.AppContext(homey=client)))
def _api(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/manager/zones/zone":
return httpx.Response(200, json=ZONES)
if request.url.path == "/api/manager/devices/device":
return httpx.Response(200, json=DEVICES)
return httpx.Response(200, json={})
def test_ids_cannot_escape_their_path_segment():
client, seen = _client(lambda r: httpx.Response(200, json={}))
async def run():
await client.trigger_flow("../../system/reboot#")
await client.set_capability("x/../../../system/reboot?", "onoff", True)
await client.aclose()
asyncio.run(run())
paths = [r.url.raw_path.decode() for r in seen]
assert paths[0] == "/api/manager/flow/flow/..%2F..%2Fsystem%2Freboot%23/trigger"
assert paths[1].startswith("/api/manager/devices/device/x%2F..%2F")
assert all("/system/reboot" not in p for p in paths)
def test_zone_resolution_prefers_exact_match():
assert srv._resolve_zone(ZONES, "wohnzimmer") == ("z2", "Wohnzimmer")
assert srv._resolve_zone(ZONES, "Wohnzimmer E") == ("z1", "Wohnzimmer Ecke")
assert srv._resolve_zone(ZONES, "ess") == ("z3", "Esszimmer")
assert srv._resolve_zone(ZONES, "zimmer") == ("z3", "Esszimmer") # shortest substring hit
assert srv._resolve_zone(ZONES, "Bad") is None
def test_find_devices_requires_all_words_first():
client, _ = _client(_api)
hits = asyncio.run(srv.find_devices(_ctx(client), "esszimmer licht"))
assert [d.id for d in hits] == ["d1"] # not every light, not every Esszimmer device
fallback = asyncio.run(srv.find_devices(_ctx(client), "licht garage"))
assert {d.id for d in fallback} == {"d1", "d2"}
def test_control_zone_uses_resolved_zone_only():
puts: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "PUT":
puts.append(request.url.path)
return httpx.Response(200, json={})
return _api(request)
client, _ = _client(handler)
result = asyncio.run(srv.control_zone(_ctx(client), "Wohnzimmer", "onoff", "off"))
assert result["zone"] == "Wohnzimmer" and result["devices"] == ["Licht Fenster"]
assert puts == ["/api/manager/devices/device/d2/capability/onoff"]
def test_control_all_refuses_unscoped_capability():
client, _ = _client(_api)
with pytest.raises(ValueError, match="Refusing"):
asyncio.run(srv.control_all(_ctx(client), "target_temperature", 30))
def test_event_entry_is_compact():
e = event_entry("homey:device:abc", "capability", {"capabilityId": "onoff", "value": True})
assert e["kind"] == "capability" and e["device_id"] == "abc"
big = {"id": "d1", "name": "Lamp", "zone": "z1", "available": True, "capabilitiesObj": {"x": 1}}
assert event_entry("homey:manager:devices", "device.update", big)["data"] == {
"id": "d1", "name": "Lamp", "zone": "z1", "available": True}
def test_bearer_mode_protects_every_mcp_route():
app = srv.build_lan_app("sse", token="s3cret", auth_mode="bearer", allowed_hosts=None)
c = TestClient(app)
assert c.get("/").status_code == 200 # discovery stays public
assert c.get("/sse").status_code == 401 # was unauthenticated before
assert c.post("/messages/?session_id=x", json={}).status_code == 401
assert c.get("/api/zones").status_code == 401
ok = c.post("/messages/?session_id=x", json={}, headers={"Authorization": "Bearer s3cret"})
assert ok.status_code != 401
wrong = c.post("/messages/?session_id=x", json={},
headers={"Authorization": "Bearer nöpe".encode()})
assert wrong.status_code == 401 # non-ASCII token no longer crashes compare_digest
http_app = srv.build_lan_app("streamable-http", token="s3cret", auth_mode="bearer",
allowed_hosts=None)
assert TestClient(http_app).post("/", json={}).status_code == 401
def test_host_guard_blocks_dns_rebinding():
hosts = srv._allowed_hosts("10.0.0.5", "")
app = srv.build_lan_app("streamable-http", token="", auth_mode="none", allowed_hosts=hosts)
c = TestClient(app)
assert c.get("/", headers={"Host": "evil.example:8080"}).status_code == 421
assert c.get("/", headers={"Host": "10.0.0.5:8080"}).status_code == 200
assert c.get("/", headers={"Host": "localhost:8080"}).status_code == 200
assert srv._allowed_hosts("0.0.0.0", "") is None
assert srv._allowed_hosts("0.0.0.0", "homey.lan") >= {"homey.lan", "localhost"}
assert srv._allowed_hosts("10.0.0.5", "*") is None
def test_tool_annotations():
tools = {t.name: t for t in asyncio.run(srv.mcp.list_tools())}
for name in ("control_all", "control_zone", "trigger_flow"):
assert tools[name].annotations.destructiveHint is True, name
for name in ("list_devices", "find_devices", "get_zone_status", "list_recent_events"):
assert tools[name].annotations.readOnlyHint is True, name