159 lines
6.5 KiB
Python
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
|