136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
|
|
"""Backend/config tests, plus the structural read-only guarantees.
|
||
|
|
|
||
|
|
No network here either: the token/unreachable paths are exercised with a
|
||
|
|
bogus token_path and a base_url nothing listens on, and the WebSocket
|
||
|
|
allowlist is asserted on the constant, not by dialing an instance.
|
||
|
|
"""
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from ha_mcp import backend as backend_mod
|
||
|
|
from ha_mcp import config as cfg
|
||
|
|
from ha_mcp.backend import BackendError, LiveBackend
|
||
|
|
from ha_mcp.config import ConfigError, get_instance
|
||
|
|
|
||
|
|
SERVICE_DIR = Path(__file__).resolve().parents[1]
|
||
|
|
|
||
|
|
|
||
|
|
# --- read-only by construction ------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_rest_client_has_no_mutating_method(repo_root):
|
||
|
|
"""scripts/ha/lib/ha_api.Client is GET-only — nothing to call by accident."""
|
||
|
|
sys.path.insert(0, str(cfg.ha_lib_dir(repo_root)))
|
||
|
|
import ha_api
|
||
|
|
|
||
|
|
public = {n for n in dir(ha_api.Client) if not n.startswith("_")}
|
||
|
|
assert public == {"get", "get_raw_text"}
|
||
|
|
for forbidden in ("post", "put", "patch", "delete", "post_config", "call_service"):
|
||
|
|
assert not hasattr(ha_api.Client, forbidden)
|
||
|
|
|
||
|
|
|
||
|
|
def test_ws_allowlist_contains_only_list_reads():
|
||
|
|
for command in backend_mod.READ_ONLY_WS_COMMANDS:
|
||
|
|
assert command.endswith("/list"), command
|
||
|
|
for mutating in (
|
||
|
|
"config/area_registry/create",
|
||
|
|
"config/entity_registry/update",
|
||
|
|
"config/device_registry/update",
|
||
|
|
"call_service",
|
||
|
|
):
|
||
|
|
assert mutating not in backend_mod.READ_ONLY_WS_COMMANDS
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_write_verbs_anywhere_in_the_package():
|
||
|
|
"""A grep-level guard: this package must never learn to POST."""
|
||
|
|
forbidden = ("requests.post", "requests.put", "requests.delete", "call_service", "post_config")
|
||
|
|
for path in (SERVICE_DIR / "src" / "ha_mcp").glob("*.py"):
|
||
|
|
text = path.read_text(encoding="utf-8")
|
||
|
|
for token in forbidden:
|
||
|
|
# backend.py names the mutating commands only inside comments
|
||
|
|
code = "\n".join(
|
||
|
|
line for line in text.splitlines() if not line.strip().startswith("#")
|
||
|
|
)
|
||
|
|
assert token not in code, f"{path.name} references {token}"
|
||
|
|
|
||
|
|
|
||
|
|
# --- instances.yaml ------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_default_instance_is_ken(repo_root):
|
||
|
|
name, instance = get_instance(None, repo_root)
|
||
|
|
assert name == "ken"
|
||
|
|
assert instance["adapter"] == "api"
|
||
|
|
assert instance["status"] == "active"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_instance_lists_the_known_ones(repo_root):
|
||
|
|
with pytest.raises(ConfigError) as exc:
|
||
|
|
get_instance("nope", repo_root)
|
||
|
|
assert "ken" in str(exc.value) and "chelsty-ha" in str(exc.value)
|
||
|
|
|
||
|
|
|
||
|
|
# --- failure paths: reportable, never a crash ---------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_token_is_a_readable_error(repo_root, tmp_path):
|
||
|
|
backend = LiveBackend(
|
||
|
|
"ken",
|
||
|
|
{"base_url": "http://127.0.0.1:1", "status": "active", "token_path": str(tmp_path / "absent")},
|
||
|
|
repo_root,
|
||
|
|
)
|
||
|
|
with pytest.raises(BackendError) as exc:
|
||
|
|
backend.states()
|
||
|
|
message = str(exc.value)
|
||
|
|
assert "no HA token" in message
|
||
|
|
assert "absent" in message # says which path it looked at
|
||
|
|
|
||
|
|
|
||
|
|
def test_offline_instance_never_opens_a_socket(repo_root):
|
||
|
|
name, instance = get_instance("chelsty-ha", repo_root)
|
||
|
|
backend = LiveBackend(name, instance, repo_root)
|
||
|
|
with pytest.raises(BackendError) as exc:
|
||
|
|
backend.states()
|
||
|
|
assert "status: offline" in str(exc.value)
|
||
|
|
|
||
|
|
|
||
|
|
def test_timeout_default_is_short():
|
||
|
|
assert backend_mod.DEFAULT_TIMEOUT == 5
|
||
|
|
|
||
|
|
|
||
|
|
# --- area index ----------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_area_index_prefers_entity_area_over_device_area():
|
||
|
|
index = backend_mod._index_from_registries(
|
||
|
|
[{"area_id": "salon", "name": "Salon"}, {"area_id": "hall", "name": "Hall"}],
|
||
|
|
[{"entity_id": "light.x", "area_id": "hall", "device_id": "d1"}],
|
||
|
|
[{"id": "d1", "area_id": "salon"}],
|
||
|
|
"websocket",
|
||
|
|
)
|
||
|
|
assert index.area_of("light.x") == "Hall"
|
||
|
|
|
||
|
|
|
||
|
|
def test_offline_area_index_falls_back_to_storage_export(repo_root):
|
||
|
|
index = backend_mod.load_offline_area_index("ken", repo_root)
|
||
|
|
assert index.source == "storage-export"
|
||
|
|
assert {a["name"] for a in index.areas} >= {"Salon", "Kuchnia", "Sypialnia"}
|
||
|
|
# and it says out loud what the offline export cannot resolve
|
||
|
|
assert "device" in index.note
|
||
|
|
|
||
|
|
|
||
|
|
def test_offline_area_index_for_an_instance_without_an_export(repo_root):
|
||
|
|
index = backend_mod.load_offline_area_index("chelsty-ha", repo_root)
|
||
|
|
assert index.source == "none"
|
||
|
|
assert index.entity_area == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_automation_files_are_repo_relative(repo_root):
|
||
|
|
files = backend_mod.automation_files("ken", repo_root)
|
||
|
|
assert len(files) > 100
|
||
|
|
rel, absolute = files[0]
|
||
|
|
assert rel.startswith("services/home-assistant/config/ken/automations/")
|
||
|
|
assert absolute.is_file()
|