"""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