"""Integration: Steward signs in to Home Assistant, reads it, and installs one update 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 time import uuid from urllib.parse import parse_qs, urlparse 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 actions it received, what it refuses, and its sign-ins.""" def __init__(self): self.calls = [] self.tokens = set() self.refreshes = [] self.revoked = [] self.refuse = False self.down = False self.admin = True self.reject_install = None self.cut_off = False 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"}}, } 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 == "/auth/token": form = parse_qs(request.content.decode()) self.refreshes.append(form) if self.refuse: return httpx.Response(400, json={"error": "invalid_grant"}) return httpx.Response(200, json={"access_token": f"access-{len(self.refreshes)}", "expires_in": 1800, "refresh_token": "refresh-new", "token_type": "Bearer"}) if path == "/auth/revoke": self.revoked.append(parse_qs(request.content.decode())["token"][0]) return httpx.Response(200) self.tokens.add(request.headers.get("authorization")) if self.refuse: return httpx.Response(401) if path == "/api/template": return httpx.Response(200, text="1") if self.admin else httpx.Response(401) if path.startswith("/api/services/"): self.calls.append((path.removeprefix("/api/services/"), json.loads(request.content))) if self.cut_off: raise httpx.ReadTimeout("no answer yet") if self.reject_install and path.endswith("update/install"): return httpx.Response(500, json={"message": self.reject_install}) return httpx.Response(200, json=[]) 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) BACKUP = ("backup/create_automatic", {}) INSTALL = ("update/install", {"entity_id": ADDON}) 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", "client_id": "http://steward.test/", "refresh_token": encrypt_secret(f"refresh-{uuid.uuid4().hex}"), "can_install": True, **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 access-1"} assert 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_sign_in_lasts_until_its_access_token_nearly_runs_out_then_is_refreshed(app): client = _ha("client") ha = FakeHomeAssistant() async def go(): await _clean(app) connection = (await _state(app))[0] first = await client.access_token(connection, ha.transport) again = await client.access_token(connection, ha.transport) assert first == again and len(ha.refreshes) == 1 for key, (token, _until) in list(client._access.items()): client._access[key] = (token, time.monotonic() - 1) return await client.access_token(connection, ha.transport) assert asyncio.run(go()) == "access-2" [form] = ha.refreshes[:1] assert form["grant_type"] == ["refresh_token"] and form["client_id"] == ["http://steward.test/"] def test_a_refused_sign_in_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 == ["/auth/token"] assert connection.refused and "Sign in again" 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 == [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 == [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 == [BACKUP, INSTALL] # 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 == [BACKUP] def test_an_action_home_assistant_is_still_working_on_counts_as_sent(app): """It answers when finished, and an update that restarts it never does.""" sched, upd = _ha("scheduler"), _ha("updates") ha = FakeHomeAssistant() ha.cut_off = True 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 run.state == "backing_up" ha.backup_lands() await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport) return (await _state(app))[1] [run] = asyncio.run(go()) assert run.state == "installing" and run.install_sent_at is not None assert ha.calls == [BACKUP, INSTALL] def test_an_install_home_assistant_rejects_fails_with_what_it_said(app): sched, upd = _ha("scheduler"), _ha("updates") ha = FakeHomeAssistant() ha.reject_install = "Not enough free space" async def go(): await _clean(app) await sched.poll(app, transport=ha.transport) await upd.start_update(app, ADDON, "someone", transport=ha.transport) ha.backup_lands() await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport) return (await _state(app))[1] [run] = asyncio.run(go()) assert run.state == "failed" and "Not enough free space" in run.message def test_an_install_that_couldnt_reach_home_assistant_is_asked_for_again(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.backup_lands() await upd.advance(app, transport=ha.transport) ha.down = True await upd.advance(app, transport=ha.transport) assert (await _state(app))[1][0].install_sent_at is None ha.down = False await upd.advance(app, transport=ha.transport) return (await _state(app))[1] [run] = asyncio.run(go()) assert run.install_sent_at is not None and ha.calls == [BACKUP, INSTALL] def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app): from datetime import timedelta 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, "not_an_admin", "isn't an administrator"), ]) 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, can_install=change != "not_an_admin") 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 == [] def _admin_client(app): from steward.models.users import User, UserRole tag = uuid.uuid4().hex[:8] uid = str(uuid.uuid4()) async def make(): async with app.db_sessionmaker() as s: async with s.begin(): s.add(User(id=uid, username=f"ha-{tag}", email=f"ha-{tag}@test", password_hash="x", role=UserRole.admin, is_active=True)) client = app.test_client() async with client.session_transaction() as sess: sess.update(user_id=uid, user_role="admin", username=f"ha-{tag}", _csrf="test-token", login_at=time.time()) return client, f"ha-{tag}" return make() CSRF = {"X-CSRF-Token": "test-token"} @pytest.mark.parametrize("admin", [True, False]) def test_signing_in_goes_to_home_assistant_and_keeps_what_comes_back(app, monkeypatch, admin): from steward.core.crypto import decrypt_secret client, m = _ha("client"), _ha("models") ha = FakeHomeAssistant() ha.admin = admin for name in ("redeem_code", "is_admin", "revoke"): real = getattr(client, name) if name == "revoke": monkeypatch.setattr(client, name, lambda c, real=real: real(c, ha.transport)) else: monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport)) async def go(): async with app.db_sessionmaker() as s: async with s.begin(): await s.execute(delete(m.HomeAssistantUpdate)) await s.execute(delete(m.HomeAssistantConnection)) web, username = await _admin_client(app) response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF, form={"url": "http://ha.test:8123/", "verify_tls": "on"}) assert response.status_code == 302 to = urlparse(response.headers["Location"]) query = parse_qs(to.query) assert f"{to.scheme}://{to.netloc}{to.path}" == "http://ha.test:8123/auth/authorize" assert query["response_type"] == ["code"] assert urlparse(query["redirect_uri"][0]).netloc == urlparse(query["client_id"][0]).netloc assert query["redirect_uri"][0].endswith("/plugins/homeassistant/connection/callback") # Coming back with someone else's state changes nothing. forged = await web.get("/plugins/homeassistant/connection/callback?code=c&state=forged") assert forged.status_code == 400 and not ha.refreshes assert (await _state(app))[0] is None response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF, form={"url": "http://ha.test:8123/", "verify_tls": "on"}) state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0] back = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}") assert back.status_code == 302 again = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}") assert again.status_code == 400 and len(ha.refreshes) == 1 # a state works once connection = (await _state(app))[0] removed = await web.post("/plugins/homeassistant/connection/delete", headers=CSRF) assert removed.status_code == 302 return connection, username, (await _state(app))[0] connection, username, after = asyncio.run(go()) assert ha.refreshes[0]["grant_type"] == ["authorization_code"] and ha.refreshes[0]["code"] == ["the-code"] assert connection.url == "http://ha.test:8123" and connection.verify_tls assert decrypt_secret(connection.refresh_token, context="test") == "refresh-new" assert connection.refresh_token != "refresh-new" assert connection.can_install is admin and connection.signed_in_by == username and connection.signed_in_at assert ha.revoked == ["refresh-new"] and after is None def test_signing_in_again_ends_the_sign_in_it_replaces(app, monkeypatch): from steward.core.crypto import encrypt_secret client = _ha("client") ha = FakeHomeAssistant() for name in ("redeem_code", "is_admin"): real = getattr(client, name) monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport)) real_revoke = client.revoke monkeypatch.setattr(client, "revoke", lambda c: real_revoke(c, ha.transport)) async def go(): await _clean(app, refresh_token=encrypt_secret("refresh-old"), refused=True) web, _ = await _admin_client(app) response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF, form={"url": "http://ha.test:8123", "verify_tls": "on"}) state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0] await web.get(f"/plugins/homeassistant/connection/callback?code=c&state={state}") return (await _state(app))[0] connection = asyncio.run(go()) assert ha.revoked == ["refresh-old"] and not connection.refused and connection.last_polled_at is None