feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
Replaces the read-only token and the webhook automation, which every install would have had to paste into its own Home Assistant. Connect now goes to Home Assistant's own sign-in page, and Steward keeps the refresh token that comes back, encrypted, and calls backup.create_automatic and update.install directly. The connection page warns that the stored credential has the same access as the user who signed in, and recommends a service account so Steward's actions are easy to tell apart in Home Assistant's logs. A user who isn't an administrator gets the status page and no Install. Those actions answer only when finished, so a request cut off once sent counts as sent and the outcome is read from the entities. Removing the connection, or signing in again, ends the old sign-in on Home Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
"""Reading what Home Assistant reports: which updates Steward may install, how
|
||||
its backups stand, the automation to add, and what the pages and MCP show."""
|
||||
its backups stand, and what the pages and MCP show."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import yaml
|
||||
|
||||
import plugins.homeassistant as homeassistant_plugin
|
||||
from plugins.homeassistant import mcp_tools, scheduler, service as s
|
||||
from steward.models.users import UserRole
|
||||
@@ -34,24 +32,20 @@ def test_only_core_os_and_add_ons_can_be_installed():
|
||||
entity_picture="https://brands.home-assistant.io/_/locks/icon.png")) is None
|
||||
|
||||
|
||||
def test_a_snapshot_keeps_waiting_updates_first_and_finds_the_automation_by_its_id():
|
||||
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("automation.renamed_by_someone", "on", id=s.AUTOMATION_ID, last_triggered=None),
|
||||
_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"]
|
||||
assert snapshot["automation"] == "on"
|
||||
backup = snapshot["backup"]
|
||||
assert backup["last_ok"] == "2026-10-31T03:00:00+00:00" and backup["next"] is None
|
||||
assert backup["last_result"] == "completed"
|
||||
assert s.automation_entity_id(NS(backup=backup)) == "automation.renamed_by_someone"
|
||||
assert s.read_snapshot([])["automation"] is None
|
||||
|
||||
|
||||
def test_a_release_link_is_only_kept_when_it_is_a_web_address():
|
||||
@@ -79,26 +73,7 @@ def test_how_the_backups_read():
|
||||
assert "reports no automatic backups" in s.backup_verdict({}, NOW)[1]
|
||||
|
||||
|
||||
def test_the_automation_to_add_only_takes_a_backup_or_installs_what_it_allows():
|
||||
text = s.automation_yaml("steward_abc")
|
||||
config = yaml.safe_load(text)
|
||||
assert config["id"] == s.AUTOMATION_ID and config["mode"] == "single"
|
||||
[trigger] = config["triggers"]
|
||||
assert trigger == {"trigger": "webhook", "webhook_id": "steward_abc", "allowed_methods": ["POST"], "local_only": True}
|
||||
assert "integration_entities('hassio')" in config["variables"]["allowed"]
|
||||
assert "'update\\\\.'" in config["variables"]["allowed"]
|
||||
assert s.SUPERVISOR in config["variables"]["allowed"]
|
||||
branches = config["actions"][0]["choose"]
|
||||
assert [b["sequence"][0]["action"] for b in branches] == ["backup.create_automatic", "update.install"]
|
||||
assert "in allowed" in branches[1]["conditions"][0]["value_template"]
|
||||
|
||||
|
||||
def test_a_pasted_secret_loses_a_terminals_wrapping():
|
||||
assert s.no_spaces(" eyJabc\n def ghi\n") == "eyJabcdefghi"
|
||||
assert s.no_spaces(None) == ""
|
||||
|
||||
|
||||
def test_a_refused_token_stops_polling_until_someone_acts():
|
||||
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)
|
||||
@@ -107,9 +82,10 @@ def test_a_refused_token_stops_polling_until_someone_acts():
|
||||
|
||||
|
||||
def _connection(**over):
|
||||
return NS(**{"url": "http://ha.example:8123", "token": "tok-s3cret", "webhook_id": "hook-s3cret",
|
||||
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,
|
||||
"automation": "on", "backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
|
||||
"backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
|
||||
"updates": [], **over})
|
||||
|
||||
|
||||
@@ -158,7 +134,7 @@ 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 and "hook-s3cret" not 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())
|
||||
@@ -170,9 +146,9 @@ async def test_install_is_held_back_while_it_couldnt_work_and_the_page_says_why(
|
||||
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
|
||||
gone = await _render(app, "admin", **_page_values(connection=_connection(automation=None)))
|
||||
assert 'action="/plugins/homeassistant/install"' not in gone
|
||||
assert "maintenance automation isn't in Home Assistant" in gone
|
||||
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_an_unconnected_page_offers_to_connect(app):
|
||||
@@ -180,13 +156,24 @@ async def test_an_unconnected_page_offers_to_connect(app):
|
||||
assert 'href="/plugins/homeassistant/connection"' in page
|
||||
|
||||
|
||||
async def test_the_form_shows_the_automation_but_never_a_saved_secret_in_a_field(app):
|
||||
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", connection=_connection(),
|
||||
v={"url": "http://ha.example:8123", "verify_tls": True, "has_token": True, "has_webhook": True},
|
||||
error=None, automation=s.automation_yaml("generated-id"))
|
||||
assert "generated-id" in page and "tok-s3cret" not in page
|
||||
assert "kept; type to change" in page
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user