v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
@@ -0,0 +1,158 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user