Files
FabledSteward/tests/plugins/homeassistant/test_service.py
T
bvandeusenandClaude Opus 5.5 4c57735dd9
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m59s
CI / publish (push) Successful in 19s
fix(homeassistant): storage reads used of total with its percentage, and an OS update is said to wait on a reboot (#6753, #6754)
The tile shows Home Assistant's own used figure beside the total, so it matches
Home Assistant's storage page. An OS update doesn't reboot the machine: Supervisor
writes the image, reports the new version and raises a repair asking for a reboot.
The confirmation, schedule warning, MCP docs and plugin docs now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:51:28 -05:00

253 lines
15 KiB
Python

"""Reading what Home Assistant reports: which updates Steward may install, how
its backups stand, and what the pages and MCP show."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import plugins.homeassistant as homeassistant_plugin
from plugins.homeassistant import mcp_tools, schedule, scheduler, service as s
from steward.models.users import UserRole
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
NOW = datetime(2026, 10, 31, 12, 0, tzinfo=timezone.utc)
def _entity(entity_id, state="on", **attrs):
return {"entity_id": entity_id, "state": state, "attributes": attrs}
def _addon(slug="core_example", state="on"):
return _entity(f"update.{slug}_update", state, title="Example add-on", installed_version="1.0",
latest_version="1.1", entity_picture=f"/api/hassio/addons/{slug}/icon")
def test_only_core_os_and_add_ons_can_be_installed():
assert s.classify(_entity(s.CORE)) == "core"
assert s.classify(_entity(s.OS)) == "os"
assert s.classify(_addon()) == "addon"
assert s.classify(_entity(s.SUPERVISOR, entity_picture="/api/brands/integration/hassio/icon.png")) is None
assert s.classify(_entity("update.switch_firmware", device_class="firmware",
entity_picture="/api/brands/integration/unifi/icon.png")) is None
assert s.classify(_entity("update.custom_locks_update",
entity_picture="https://brands.home-assistant.io/_/locks/icon.png")) is None
def test_a_snapshot_keeps_waiting_updates_first():
snapshot = s.read_snapshot([
_addon("core_quiet", "off"), _entity("update.switch_firmware", title="Switch"),
_addon(), _entity(s.CORE, title="Home Assistant Core"),
_entity("update.gone", "unavailable"),
_entity(s.LAST_BACKUP, "2026-10-31T03:00:00+00:00"), _entity(s.NEXT_BACKUP, "unknown"),
_entity(s.BACKUP_EVENT, "2026-10-31T03:00:00+00:00", event_type="completed"),
_entity("light.kitchen", "on"),
])
assert [u["entity_id"] for u in snapshot["updates"]] == [
s.CORE, "update.core_example_update", "update.switch_firmware", "update.core_quiet_update"]
backup = snapshot["backup"]
assert backup["last_ok"] == "2026-10-31T03:00:00+00:00" and backup["next"] is None
assert backup["last_result"] == "completed"
def test_a_release_link_is_only_kept_when_it_is_a_web_address():
assert s.read_snapshot([_entity(s.CORE, release_url="javascript:alert(1)")])["updates"][0]["release_url"] is None
assert s.read_snapshot([_entity(s.CORE, release_url="https://example.com/r")])["updates"][0]["release_url"]
def test_newer_is_judged_on_the_readings_alone():
assert s.is_newer("2026-10-31T03:00:01+00:00", "2026-10-31T03:00:00+00:00")
assert not s.is_newer("2026-10-31T03:00:00+00:00", "2026-10-31T03:00:00+00:00")
assert not s.is_newer("2026-10-30T03:00:00+00:00", "2026-10-31T03:00:00+00:00")
assert s.is_newer("2026-10-31T03:00:00+00:00", "unknown")
assert not s.is_newer("unavailable", "2026-10-31T03:00:00+00:00")
def test_how_the_backups_read():
ok = {"known": True, "last_ok": (NOW - timedelta(hours=25)).isoformat(), "last_result": "completed"}
assert s.backup_verdict(ok, NOW)[0] == "ok"
assert abs(s.backup_age_hours(ok, NOW) - 25) < 0.01
stale = {**ok, "last_ok": (NOW - timedelta(hours=27)).isoformat()}
assert s.backup_verdict(stale, NOW) == ("warn", "The last good backup is 27 hours old, past the 26 hours expected.")
failed = {**ok, "last_result": "failed", "failed_reason": "upload_failed"}
assert "failed (upload_failed)" in s.backup_verdict(failed, NOW)[1]
assert "no successful" in s.backup_verdict({"known": True, "last_ok": None}, NOW)[1]
assert "reports no automatic backups" in s.backup_verdict({}, NOW)[1]
def test_a_refused_sign_in_stops_polling_until_someone_acts():
fresh = NS(refused=False, last_polled_at=NOW - timedelta(seconds=60))
assert not scheduler.is_due(fresh, NOW)
assert scheduler.is_due(NS(refused=False, last_polled_at=NOW - timedelta(seconds=300)), NOW)
assert scheduler.is_due(NS(refused=False, last_polled_at=None), NOW)
assert not scheduler.is_due(NS(refused=True, last_polled_at=None), NOW)
def _connection(**over):
return NS(**{"url": "http://ha.example:8123", "refresh_token": "tok-s3cret", "can_install": True,
"verify_tls": True, "signed_in_by": "someone", "signed_in_at": NOW,
"refused": False, "last_error": None, "last_polled_at": NOW, "last_ok_at": NOW,
"backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
"updates": [], "health": {}, **over})
def test_the_overview_names_what_needs_a_look_and_no_more():
assert mcp_tools.overview_findings(None, None, NOW) == []
assert mcp_tools.overview_findings(_connection(), None, NOW) == []
waiting = _connection(updates=[{"waiting": True, "kind": "core", "title": "Core"}])
assert mcp_tools.overview_findings(waiting, None, NOW) == []
down = mcp_tools.overview_findings(_connection(last_error="Couldn't reach Home Assistant."), None, NOW)
assert [f["kind"] for f in down] == ["home_assistant_down"]
stale = _connection(backup={"known": True, "last_ok": (NOW - timedelta(days=2)).isoformat()})
assert [f["kind"] for f in mcp_tools.overview_findings(stale, None, NOW)] == ["home_assistant_backup"]
failed = NS(state="failed", title="Core", message="No new backup.", finished_at=NOW)
[f] = mcp_tools.overview_findings(_connection(), failed, NOW)
assert f["kind"] == "home_assistant_update_failed" and "No new backup." in f["summary"]
two = {"problems": [{"title": "Disk trouble"}, {"title": "Another"}]}
[f] = mcp_tools.overview_findings(_connection(health=two), None, NOW)
assert f["kind"] == "home_assistant_problem" and f["summary"] == "Disk trouble (and 1 more)"
plan = NS(stopped="Core: it failed.", last_slot_at=None)
[f] = mcp_tools.overview_findings(_connection(), None, NOW, plan)
assert f["kind"] == "home_assistant_schedule_stopped" and "Core: it failed." in f["summary"]
def test_system_problems_are_read_from_what_home_assistant_answers():
healthy = s.read_health({"config": {"recovery_mode": False}, "issues": [], "resolution": {"unhealthy": [], "unsupported": []},
"host": {"disk_free": 99.8, "disk_used": 12.0, "disk_total": 116.5},
"addons": [{"slug": "a", "state": "started"}]})
assert healthy["problems"] == [] and healthy["repairs_read"] and healthy["supervised"]
assert healthy["disk_free_pct"] == 85.7 and healthy["disk_used_gb"] == 12.0 and healthy["disk_used_pct"] == 10.3
bad = s.read_health({
"config": {"recovery_mode": True},
"issues": [{"domain": "hassio", "translation_key": "issue_system_reboot_required", "severity": "warning"},
{"domain": "demo", "translation_key": "named", "translation_placeholders": {"who": "X"}, "severity": "critical"},
{"domain": "demo", "translation_key": "gone", "ignored": True},
{"domain": "demo", "translation_key": "old", "dismissed_version": "1.0"}],
"titles": {"component.demo.issues.named.title": "{who} broke"},
"resolution": {"unhealthy": ["docker_gateway"], "unsupported": []},
"host": {"disk_free": 5.0, "disk_total": 100.0},
"addons": [{"slug": "should", "name": "Should run", "state": "stopped"}, {"slug": "once", "state": "stopped"}],
"boots": {"should": "auto", "once": "manual"}})
assert [p["kind"] for p in bad["problems"]] == ["recovery", "repair", "repair", "supervisor", "disk", "addon"]
titles = [p["title"] for p in bad["problems"]]
assert titles[1] == "System reboot required" and titles[2] == "X broke" and "docker gateway" in titles[3]
assert "5 GB of 100 GB" in titles[4] and "Should run" in titles[5]
def test_what_home_assistant_wouldnt_answer_is_not_read_as_fine():
found = s.read_health({"config": {}, "issues": None, "resolution": None, "host": None, "addons": None})
assert not found["repairs_read"] and not found["supervised"]
assert found["disk_free_pct"] is None and found["disk_used_pct"] is None
def test_a_schedule_installs_add_ons_then_core_then_the_os():
def update(entity_id, kind, in_progress=False):
return {"entity_id": entity_id, "kind": kind, "in_progress": in_progress}
waiting = [update("os", "os"), update("core", "core"), update("addon", "addon")]
assert schedule.next_to_install(waiting, set())["entity_id"] == "addon"
assert schedule.next_to_install(waiting, {"addon"})["entity_id"] == "core"
assert schedule.next_to_install(waiting, {"addon", "core"})["entity_id"] == "os"
assert schedule.next_to_install(waiting, {"addon", "core", "os"}) is None
assert schedule.next_to_install([update("addon", "addon", in_progress=True)], set()) is None
def test_reading_is_open_to_a_viewer_and_installing_is_for_an_admin():
seen = {}
def register(fn, *, role, read_only): # both required, as on the server
seen[fn.__name__] = (role, read_only)
homeassistant_plugin.register_mcp_tools(register)
assert seen == {"get_home_assistant": (UserRole.viewer, True), "update_home_assistant": (UserRole.admin, False),
"set_home_assistant_schedule": (UserRole.admin, False)}
def _page_values(**over):
update = {"entity_id": s.CORE, "title": "Core", "kind": "core", "kind_word": "Core", "installed": "1.0",
"latest": "1.1", "waiting": True, "in_progress": False, "auto_update": False,
"release_url": "https://example.com/r"}
return {"connection": _connection(), "reach": "up", "says": "Answering.", "installable": [update], "others": [],
"current": [], "runs": [], "under_way": None, "backup": {}, "backup_tone": "ok",
"backup_says": "Backups are up to date.", "backup_at": NOW, "backup_next": None, "backup_age": 1.0,
"stale_hours": 26, "health": {"repairs_read": True, "supervised": True}, "problems": [], "plan": None,
"plan_when": None, "plan_next": None, "weekdays": ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday"), "default_zone": "UTC", **over}
async def _render(app, role, **values):
from quart import session
async with app.test_request_context("/plugins/homeassistant/"):
session["user_role"] = role
page = await render_plugin_template(app, "homeassistant", "homeassistant/index.html", **values)
return " ".join(page.split())
async def test_an_admin_is_asked_before_an_install_and_a_viewer_cant_install(app):
page = await _render(app, "admin", **_page_values())
assert "1 update waiting" in page and 'action="/plugins/homeassistant/install"' in page
assert "Steward takes a backup first" in page and "Home Assistant restarts." in page
assert "tok-s3cret" not in page
main = page[page.index("<main"):]
assert not hex_colours(main), hex_colours(main)
assert 'action="/plugins/homeassistant/install"' not in await _render(app, "viewer", **_page_values())
async def test_install_is_held_back_while_it_couldnt_work_and_the_page_says_why(app):
run = NS(id="r", title="Core", state="backing_up", from_version="1.0", to_version="1.1", started_by="someone",
started_at=NOW, message=None)
busy = await _render(app, "admin", **_page_values(under_way=run, runs=[run]))
assert 'action="/plugins/homeassistant/install"' not in busy and "taking a backup first" in busy
assert 'hx-trigger="every 15s"' in busy
status_only = await _render(app, "admin", **_page_values(connection=_connection(can_install=False)))
assert 'action="/plugins/homeassistant/install"' not in status_only
assert "isn't an administrator" in status_only
async def test_the_page_shows_system_problems_and_what_it_couldnt_read(app):
clear = await _render(app, "viewer", **_page_values())
assert "No repairs open" in clear
problem = {"kind": "repair", "severity": "warning", "title": "System reboot required"}
shown = await _render(app, "viewer", **_page_values(problems=[problem]))
assert "System reboot required" in shown and "Settings → System → Repairs" in shown
hidden = await _render(app, "viewer", **_page_values(health={"repairs_read": False, "supervised": False}))
assert "wouldn't share its repairs" in hidden
bare = await _render(app, "viewer", **_page_values(health={"repairs_read": True, "supervised": False}))
assert "No Supervisor answered" in bare
async def test_an_admin_sets_the_schedule_warned_of_what_it_does_and_a_viewer_only_reads_it(app):
off = await _render(app, "admin", **_page_values())
assert 'action="/plugins/homeassistant/schedule"' in off and "Off. Updates are only installed" in off
assert "with nobody there to watch" in off
assert '<option value="5" selected>Saturday</option>' in off and 'value="05:00"' in off
plan = NS(enabled=True, weekday=5, at_time="05:00", time_zone="Europe/London", stopped="Core: it failed.")
values = _page_values(plan=plan, plan_when="Saturdays at 05:00 (Europe/London), up to 4 h", plan_next=NOW)
on = await _render(app, "viewer", **values)
assert "Saturdays at 05:00" in on and "Core: it failed." in on
assert 'action="/plugins/homeassistant/schedule"' not in on
async def test_an_unconnected_page_offers_to_connect(app):
page = await _render(app, "admin", connection=None)
assert 'href="/plugins/homeassistant/connection"' in page
async def _form(app, **values):
from quart import session
async with app.test_request_context("/plugins/homeassistant/connection"):
session["user_role"] = "admin"
page = await render_plugin_template(app, "homeassistant", "homeassistant/form.html", error=None,
url="http://ha.example:8123", verify_tls=True, **values)
return " ".join(page.split())
async def test_the_form_warns_what_the_credential_can_do_and_recommends_a_service_account(app):
page = await _form(app, connection=None)
assert "same access as the user who signs in" in page and "including controlling every device" in page
assert "We recommend a service account" in page and "in Home Assistant's logs" in page
assert 'action="/plugins/homeassistant/connection/sign-in"' in page and "Continue to Home Assistant" in page
assert "connection/delete" not in page
async def test_a_connected_form_says_who_signed_in_and_never_shows_the_credential(app):
page = await _form(app, connection=_connection())
assert "tok-s3cret" not in page and "by someone" in page and "an administrator" in page
assert "Sign in again" in page and "connection/delete" in page