Files
FabledSteward/tests/integration/test_homeassistant.py
T
bvandeusenandClaude Opus 5.5 b2a6e92ed3
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped
feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
A new plugin reads Home Assistant with a read-only token every 5 minutes and
shows which Core, OS and add-on updates are waiting and how old the last backup
is. Install takes a backup through a local-only webhook, confirms Home
Assistant's last-backup time moved, installs, and follows the update to its end,
through Home Assistant's and Steward's own restarts. One runs at a time. Each
ends as one timeline entry.

Steward holds nothing that can act on the house: the webhook's automation can
only take a backup or install an allowed update (System 231's ruling). A refused
token stops polling, as Home Assistant bans an address after a few refusals.

Milestone 576 steps 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:09:27 -04:00

291 lines
12 KiB
Python

"""Integration: Steward reads Home Assistant, and installs one update through its
webhook only after a newer backup, following it to its end. A fake Home Assistant
answers over httpx's mock transport. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import importlib
import json
import os
import uuid
import httpx
import pytest
from sqlalchemy import delete, select
pytestmark = pytest.mark.integration
ADDON = "update.example_update"
BEFORE, AFTER = "2026-10-30T03:00:00+00:00", "2026-10-31T03:00:00+00:00"
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def _ha(sub):
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("homeassistant")
base = loaded.__name__ if loaded is not None else "plugins.homeassistant"
return importlib.import_module(f"{base}.{sub}")
class FakeHomeAssistant:
"""Its entities, the webhook calls it received, and what it refuses."""
def __init__(self):
self.calls = []
self.tokens = set()
self.refuse = False
self.down = False
self.hook_runs = True
self.entities = {
ADDON: self._update("on", "1.0"),
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
"event.backup_automatic_backup": {"state": BEFORE, "attributes": {"event_type": "completed"}},
"automation.steward_maintenance": {"state": "on", "attributes": {"id": "steward_maintenance",
"last_triggered": None}},
}
def _update(self, state, installed):
return {"state": state, "attributes": {"title": "Example", "installed_version": installed,
"latest_version": "1.1", "in_progress": False,
"entity_picture": "/api/hassio/addons/example/icon"}}
def backup_lands(self):
self.entities["sensor.backup_last_successful_automatic_backup"]["state"] = AFTER
def update_lands(self):
self.entities[ADDON] = self._update("off", "1.1")
def handler(self, request: httpx.Request) -> httpx.Response:
if self.down:
raise httpx.ConnectError("connection refused")
path = request.url.path
if path.startswith("/api/webhook/"):
assert "authorization" not in request.headers
self.calls.append(json.loads(request.content))
if self.hook_runs:
self.entities["automation.steward_maintenance"]["attributes"]["last_triggered"] = AFTER
return httpx.Response(200)
self.tokens.add(request.headers.get("authorization"))
if self.refuse:
return httpx.Response(401)
if path == "/api/states":
return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()])
entity = self.entities.get(path.removeprefix("/api/states/"))
return httpx.Response(200, json=entity) if entity else httpx.Response(404)
@property
def transport(self):
return httpx.MockTransport(self.handler)
async def _clean(app, **connection):
from steward.core.crypto import encrypt_secret
m = _ha("models")
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(m.HomeAssistantUpdate))
await s.execute(delete(m.HomeAssistantConnection))
s.add(m.HomeAssistantConnection(url="http://ha.test:8123", token=encrypt_secret("read-only"),
webhook_id=encrypt_secret(f"hook{uuid.uuid4().hex}"), **connection))
async def _state(app):
m = _ha("models")
async with app.db_sessionmaker() as s:
connection = (await s.execute(select(m.HomeAssistantConnection))).scalars().first()
runs = (await s.execute(select(m.HomeAssistantUpdate))).scalars().all()
return connection, runs
def test_a_poll_stores_what_it_read_and_records_its_metrics(app):
from steward.models.metrics import PluginMetricLatest
sched = _ha("scheduler")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
connection, _ = await _state(app)
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(PluginMetricLatest).where(
PluginMetricLatest.source_module == "homeassistant"))).scalars().all()
return connection, {r.metric_name: r.value for r in rows}
connection, metrics = asyncio.run(go())
assert ha.tokens == {"Bearer read-only"}
assert connection.automation == "on" and connection.last_error is None and not connection.refused
assert [u["entity_id"] for u in connection.updates] == [ADDON]
assert connection.backup["last_ok"] == BEFORE
assert metrics["is_up"] == 1.0 and metrics["updates_waiting"] == 1.0 and metrics["backup_age_hours"] >= 0
def test_a_refused_token_is_asked_once_and_not_again(app):
sched = _ha("scheduler")
ha = FakeHomeAssistant()
ha.refuse = True
seen = []
real = ha.handler
def counted(request):
seen.append(request.url.path)
return real(request)
transport = httpx.MockTransport(counted)
async def go():
await _clean(app)
await sched.poll(app, transport=transport)
await sched.poll(app, transport=transport)
return (await _state(app))[0]
connection = asyncio.run(go())
assert seen == ["/api/states"]
assert connection.refused and "refused the token" in connection.last_error
def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeline(app):
from steward.models.timeline import TimelineEntry
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
assert ha.calls == [{"action": "backup"}]
assert run.state == "backing_up" and run.from_version == "1.0" and run.to_version == "1.1"
# No newer backup yet: nothing is installed, however often it is asked.
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}]
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
_, [row] = await _state(app)
assert row.state == "installing" and row.backup_at is not None
await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}, {"action": "install", "entity_id": ADDON}]
# Home Assistant restarting is expected, and isn't the end of the update.
ha.down = True
await upd.advance(app, transport=ha.transport)
_, [row] = await _state(app)
assert row.state == "installing"
ha.down = False
ha.update_lands()
await upd.advance(app, transport=ha.transport)
connection, [row] = await _state(app)
assert row.state == "done" and row.finished_at is not None
assert connection.last_polled_at is None # the page shows the new version at the next tick
await upd.advance(app, transport=ha.transport)
assert len(ha.calls) == 2
async with app.db_sessionmaker() as s:
entries = (await s.execute(select(TimelineEntry).where(
TimelineEntry.ref_type == "homeassistant_update", TimelineEntry.ref_id == row.id))).scalars().all()
return entries
[entry] = asyncio.run(go())
assert entry.type == "homeassistant.updated" and entry.actor == "someone"
assert "1.0 to 1.1" in entry.summary and entry.area == "service"
def test_a_failed_backup_installs_nothing(app):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
ha.entities["event.backup_automatic_backup"] = {
"state": AFTER, "attributes": {"event_type": "failed", "failed_reason": "upload_failed"}}
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "upload_failed" in run.message and "Nothing was installed" in run.message
assert ha.calls == [{"action": "backup"}]
def test_a_webhook_that_never_ran_the_automation_says_so(app):
from datetime import timedelta
from sqlalchemy import update
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
ha = FakeHomeAssistant()
ha.hook_runs = False
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
await upd.advance(app, transport=ha.transport)
assert (await _state(app))[1][0].state == "backing_up" # within its grace
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(m.HomeAssistantUpdate).where(m.HomeAssistantUpdate.id == run.id).values(
started_at=run.started_at - timedelta(seconds=upd.TRIGGER_GRACE_S + 1)))
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "webhook id" in run.message and "Nothing was installed" in run.message
def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
from datetime import timedelta
from sqlalchemy import update
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
with pytest.raises(upd.CannotStart, match="under way"):
await upd.start_update(app, ADDON, "someone else", transport=ha.transport)
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport)
async with app.db_sessionmaker() as s:
async with s.begin():
row = await s.get(m.HomeAssistantUpdate, run.id)
row.install_sent_at -= timedelta(seconds=upd.INSTALL_DEADLINE_S["addon"] + 1)
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "Still not on 1.1" in run.message
assert len(ha.calls) == 2
@pytest.mark.parametrize("entity,change,says", [
("update.unknown", None, "isn't a Core, OS or add-on"),
(ADDON, "no_longer_waiting", "no longer waiting"),
(ADDON, "no_automation", "automation isn't on"),
])
def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
if change == "no_automation":
del ha.entities["automation.steward_maintenance"]
await sched.poll(app, transport=ha.transport)
if change == "no_longer_waiting":
ha.update_lands()
with pytest.raises(upd.CannotStart, match=says):
await upd.start_update(app, entity, "someone", transport=ha.transport)
return (await _state(app))[1]
assert asyncio.run(go()) == []
assert ha.calls == []