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,5 +1,5 @@
|
||||
"""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
|
||||
"""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
|
||||
|
||||
@@ -7,7 +7,9 @@ import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -35,20 +37,22 @@ def _ha(sub):
|
||||
|
||||
|
||||
class FakeHomeAssistant:
|
||||
"""Its entities, the webhook calls it received, and what it refuses."""
|
||||
"""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.hook_runs = True
|
||||
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"}},
|
||||
"automation.steward_maintenance": {"state": "on", "attributes": {"id": "steward_maintenance",
|
||||
"last_triggered": None}},
|
||||
}
|
||||
|
||||
def _update(self, state, installed):
|
||||
@@ -66,15 +70,28 @@ class FakeHomeAssistant:
|
||||
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
|
||||
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/"))
|
||||
@@ -85,6 +102,10 @@ class FakeHomeAssistant:
|
||||
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")
|
||||
@@ -92,8 +113,9 @@ async def _clean(app, **connection):
|
||||
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))
|
||||
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):
|
||||
@@ -119,14 +141,33 @@ def test_a_poll_stores_what_it_read_and_records_its_metrics(app):
|
||||
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 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_refused_token_is_asked_once_and_not_again(app):
|
||||
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
|
||||
@@ -145,8 +186,8 @@ def test_a_refused_token_is_asked_once_and_not_again(app):
|
||||
return (await _state(app))[0]
|
||||
|
||||
connection = asyncio.run(go())
|
||||
assert seen == ["/api/states"]
|
||||
assert connection.refused and "refused the token" in connection.last_error
|
||||
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):
|
||||
@@ -158,20 +199,20 @@ def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeli
|
||||
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 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 == [{"action": "backup"}]
|
||||
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 == [{"action": "backup"}, {"action": "install", "entity_id": ADDON}]
|
||||
assert ha.calls == [BACKUP, INSTALL]
|
||||
|
||||
# Home Assistant restarting is expected, and isn't the end of the update.
|
||||
ha.down = True
|
||||
@@ -212,31 +253,68 @@ def test_a_failed_backup_installs_nothing(app):
|
||||
|
||||
[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"}]
|
||||
assert ha.calls == [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")
|
||||
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.hook_runs = False
|
||||
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)
|
||||
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
|
||||
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):
|
||||
@@ -268,16 +346,14 @@ def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
|
||||
@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"),
|
||||
(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)
|
||||
if change == "no_automation":
|
||||
del ha.entities["automation.steward_maintenance"]
|
||||
await _clean(app, can_install=change != "not_an_admin")
|
||||
await sched.poll(app, transport=ha.transport)
|
||||
if change == "no_longer_waiting":
|
||||
ha.update_lands()
|
||||
@@ -287,3 +363,102 @@ def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
|
||||
|
||||
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
|
||||
|
||||
@@ -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