Files
core/tests/components/repairs/test_websocket_api.py
T

901 lines
26 KiB
Python

"""Test the repairs websocket API."""
from collections.abc import Iterator
from contextlib import contextmanager
from http import HTTPStatus
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock
import orjson
import pytest
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import FlowType, RepairsFlow, RepairsFlowResult
from homeassistant.components.repairs.const import DOMAIN
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import __version__ as ha_version
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import (
MockConfigEntry,
MockModule,
MockUser,
mock_config_flow,
mock_integration,
mock_platform,
)
from tests.typing import (
ClientSessionGenerator,
MockHAClientWebSocket,
WebSocketGenerator,
)
DEFAULT_ISSUES = [
{
"breaks_in_ha_version": "2022.9",
"domain": "fake_integration",
"issue_id": "issue_1",
"is_fixable": True,
"learn_more_url": "https://theuselessweb.com",
"severity": "error",
"translation_key": "abc_123",
"translation_placeholders": {"abc": "123"},
}
]
async def create_issues(
hass: HomeAssistant,
ws_client: MockHAClientWebSocket,
issues: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Create issues."""
def api_issue(issue):
excluded_keys = ("data",)
return dict(
{key: issue[key] for key in issue if key not in excluded_keys},
created=ANY,
dismissed_version=None,
ignored=False,
issue_domain=None,
)
if issues is None:
issues = DEFAULT_ISSUES
for issue in issues:
ir.async_create_issue(
hass,
issue["domain"],
issue["issue_id"],
breaks_in_ha_version=issue["breaks_in_ha_version"],
data=issue.get("data"),
is_fixable=issue["is_fixable"],
is_persistent=False,
learn_more_url=issue["learn_more_url"],
severity=issue["severity"],
translation_key=issue["translation_key"],
translation_placeholders=issue["translation_placeholders"],
)
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"] == {"issues": [api_issue(issue) for issue in issues]}
return issues
EXPECTED_DATA = {
"issue_1": None,
"issue_2": {"blah": "bleh"},
"abort_issue1": None,
"issue_3": None,
"invalid_flow": None,
"unknown_entry": None,
"unknown_entry_via_form": None,
}
class MockFixFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
assert self.issue_id in EXPECTED_DATA
assert self.data == EXPECTED_DATA[self.issue_id]
return await self.async_step_custom_step()
async def async_step_custom_step(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle a custom_step step of a fix flow."""
if user_input is not None:
return self.async_create_entry(data={})
return self.async_show_form(step_id="custom_step", data_schema=vol.Schema({}))
class MockFixFlowAbort(RepairsFlow):
"""Handler for an issue fixing flow that aborts."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return self.async_abort(reason="not_given")
@pytest.fixture(autouse=True)
async def mock_repairs_integration(hass: HomeAssistant) -> None:
"""Mock a repairs integration."""
hass.config.components.add("fake_integration")
def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
assert issue_id in EXPECTED_DATA
assert data == EXPECTED_DATA[issue_id]
if issue_id == "abort_issue1":
return MockFixFlowAbort()
if issue_id in [
"issue_3",
"invalid_flow",
"unknown_entry",
"unknown_entry_via_form",
]:
return MockFixFlowNextFlow()
return MockFixFlow()
mock_platform(
hass,
"fake_integration.repairs",
Mock(async_create_fix_flow=AsyncMock(wraps=async_create_fix_flow)),
)
mock_platform(
hass,
"integration_without_repairs.repairs",
Mock(spec=[]),
)
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_dismiss_issue(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test we can dismiss an issue."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
issues = await create_issues(hass, client)
await client.send_json(
{
"id": 2,
"type": "repairs/ignore_issue",
"domain": "fake_integration",
"issue_id": "no_such_issue",
"ignore": True,
}
)
msg = await client.receive_json()
assert not msg["success"]
await client.send_json(
{
"id": 3,
"type": "repairs/ignore_issue",
"domain": "fake_integration",
"issue_id": "issue_1",
"ignore": True,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
await client.send_json({"id": 4, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"issues": [
dict(
issue,
created=ANY,
dismissed_version=ha_version,
ignored=True,
issue_domain=None,
)
for issue in issues
]
}
await client.send_json(
{
"id": 5,
"type": "repairs/ignore_issue",
"domain": "fake_integration",
"issue_id": "issue_1",
"ignore": False,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
await client.send_json({"id": 6, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"issues": [
dict(
issue,
created=ANY,
dismissed_version=None,
ignored=False,
issue_domain=None,
)
for issue in issues
]
}
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_fix_non_existing_issue(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test trying to fix an issue that doesn't exist."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
issues = await create_issues(hass, ws_client)
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "no_such_integration", "issue_id": "no_such_issue"}
)
assert resp.status != HTTPStatus.OK
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "no_such_issue"}
)
assert resp.status != HTTPStatus.OK
await ws_client.send_json({"id": 3, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"] == {
"issues": [
dict(
issue,
created=ANY,
dismissed_version=None,
ignored=False,
issue_domain=None,
)
for issue in issues
]
}
@pytest.mark.parametrize(
(
"domain",
"step",
"description_placeholders",
"ignore_translations_for_mock_domains",
),
[
("fake_integration", "custom_step", None, ["fake_integration"]),
(
"fake_integration_default_handler",
"confirm",
{"abc": "123"},
["fake_integration_default_handler"],
),
],
)
async def test_fix_issue(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
domain,
step,
description_placeholders,
) -> None:
"""Test we can fix an issue."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
issues = [
{
**DEFAULT_ISSUES[0],
"data": {"blah": "bleh"},
"domain": domain,
"issue_id": "issue_2",
}
]
await create_issues(hass, ws_client, issues=issues)
url = "/api/repairs/issues/fix"
resp = await client.post(url, json={"handler": domain, "issue_id": "issue_2"})
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"data_schema": [],
"description_placeholders": description_placeholders,
"errors": None,
"flow_id": ANY,
"handler": domain,
"last_step": None,
"preview": None,
"step_id": step,
"type": "form",
}
url = f"/api/repairs/issues/fix/{flow_id}"
# Test we can get the status of the flow
resp2 = await client.get(url)
assert resp2.status == HTTPStatus.OK
data2 = await resp2.json()
assert data == data2
resp = await client.post(url)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"description": None,
"description_placeholders": None,
"flow_id": flow_id,
"handler": domain,
"type": "create_entry",
}
await ws_client.send_json({"id": 4, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"] == {"issues": []}
@contextmanager
def mock_core_config_flow() -> Iterator[None]:
"""Mock a config flow."""
class CompConfigFlow(ConfigFlow):
"""Config flow with options and subentries flow."""
async def async_step_user(self, user_input=None):
return self.async_show_form(step_id="user")
async def async_step_reconfigure(self, user_input=None):
return self.async_show_form(step_id="reconfigure")
with mock_config_flow("comp", CompConfigFlow):
yield
class MockFixFlowNextFlow(RepairsFlow):
"""Mock flow fix supporting `next_flow`."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
assert self.issue_id in EXPECTED_DATA
mock_integration(self.hass, MockModule("comp"))
mock_platform(self.hass, "comp.config_flow", None)
entries = self.hass.config_entries.async_entries("comp")
assert len(entries) == 1
mock_entry: MockConfigEntry = entries[0]
if self.issue_id == "unknown_entry_via_form":
return await self.async_step_user()
with mock_core_config_flow():
flow_type: str = FlowType.CONFIG_FLOW
match self.issue_id:
case "issue_3":
next_flow = await mock_entry.start_reconfigure_flow(self.hass)
case "invalid_flow":
flow_type = "fake_flow_type"
next_flow = {"flow_id": "fake_flow_id"}
case "unknown_entry":
next_flow = await mock_entry.start_reconfigure_flow(self.hass)
# Remove the entry to trigger UnknownEntry error.
await self.hass.config_entries.async_remove(mock_entry.entry_id)
return self.async_create_entry(
data={},
next_flow=(
flow_type,
next_flow["flow_id"],
),
)
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle an UnknownEntry error via a form.
Test RepairsFlowResourceView.post error handling.
"""
if user_input:
entries = self.hass.config_entries.async_entries("comp")
mock_entry: MockConfigEntry = entries[0]
next_flow = await mock_entry.start_reconfigure_flow(self.hass)
await self.hass.config_entries.async_remove(mock_entry.entry_id)
return self.async_create_entry(
data={}, next_flow=(FlowType.CONFIG_FLOW, next_flow["flow_id"])
)
return self.async_show_form(step_id="user")
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_fix_issue_next_flow(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test next_flow RepairFlows."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
issues = [{**DEFAULT_ISSUES[0], "issue_id": "issue_3"}]
await create_issues(hass, ws_client, issues=issues)
mock_entry = MockConfigEntry(
domain="comp",
data={},
)
mock_entry.add_to_hass(hass)
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "issue_3"}
)
assert resp.status == HTTPStatus.OK, (
f"Error: {resp.status} cause {await resp.text()}"
)
data = await resp.json()
_, next_flow_id = data["next_flow"]
assert data == {
"description_placeholders": None,
"flow_id": ANY,
"handler": "fake_integration",
"description": None,
"type": data_entry_flow.FlowResultType.CREATE_ENTRY,
"next_flow": [
FlowType.CONFIG_FLOW,
next_flow_id,
],
"result": orjson.loads(orjson.dumps(mock_entry.as_json_fragment)),
}
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_fix_issue_next_flow_errors(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test next_flow RepairFlows."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
issues = [
{
**DEFAULT_ISSUES[0],
"issue_id": "invalid_flow",
},
{
**DEFAULT_ISSUES[0],
"issue_id": "unknown_entry",
},
{
**DEFAULT_ISSUES[0],
"issue_id": "unknown_entry_via_form",
},
]
await create_issues(hass, ws_client, issues=issues)
mock_entry = MockConfigEntry(
domain="comp",
data={},
)
mock_entry.add_to_hass(hass)
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "invalid_flow"}
)
assert resp.status == HTTPStatus.NOT_FOUND
data = await resp.json()
assert "Invalid next_flow FlowType" in data["message"]
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "unknown_entry"}
)
assert resp.status == HTTPStatus.BAD_REQUEST
data = await resp.json()
assert "not found in next_flow" in data["message"]
# Re add removed mock entry
mock_entry = MockConfigEntry(
domain="comp",
data={},
)
mock_entry.add_to_hass(hass)
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "unknown_entry_via_form"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "form"
resp = await client.post(f"{url}/{data['flow_id']}", json={"submit": "True"})
assert resp.status == HTTPStatus.BAD_REQUEST
data = await resp.json()
assert "not found in next_flow" in data["message"]
async def test_fix_issue_unauth(
hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser
) -> None:
"""Test we can't query the result if not authorized."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
hass_admin_user.groups = []
client = await hass_client()
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "issue_1"}
)
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_get_progress_unauth(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test we can't fix an issue if not authorized."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
await create_issues(hass, ws_client)
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "issue_1"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
hass_admin_user.groups = []
url = f"/api/repairs/issues/fix/{flow_id}"
# Test we can't get the status of the flow
resp = await client.get(url)
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_step_unauth(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test we can't fix an issue if not authorized."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
await create_issues(hass, ws_client)
url = "/api/repairs/issues/fix"
resp = await client.post(
url, json={"handler": "fake_integration", "issue_id": "issue_1"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
hass_admin_user.groups = []
url = f"/api/repairs/issues/fix/{flow_id}"
# Test we can't get the status of the flow
resp = await client.post(url)
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
@pytest.mark.freeze_time("2022-07-19 07:53:05")
async def test_list_issues(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test we can list issues."""
# Add an inactive issue, this should not be exposed in the list
hass_storage[ir.STORAGE_KEY] = {
"version": ir.STORAGE_VERSION_MAJOR,
"data": {
"issues": [
{
"created": "2022-07-19T09:41:13.746514+00:00",
"dismissed_version": None,
"domain": "test",
"is_persistent": False,
"issue_id": "issue_3_inactive",
"issue_domain": None,
},
]
},
}
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {"issues": []}
issues = [
{
"breaks_in_ha_version": "2022.9",
"domain": "test",
"is_fixable": True,
"issue_id": "issue_1",
"issue_domain": None,
"learn_more_url": "https://theuselessweb.com",
"severity": "error",
"translation_key": "abc_123",
"translation_placeholders": {"abc": "123"},
},
{
"breaks_in_ha_version": "2022.8",
"domain": "test",
"is_fixable": False,
"issue_id": "issue_2",
"issue_domain": None,
"learn_more_url": "https://theuselessweb.com/abc",
"severity": "other",
"translation_key": "even_worse",
"translation_placeholders": {"def": "456"},
},
]
for issue in issues:
ir.async_create_issue(
hass,
issue["domain"],
issue["issue_id"],
breaks_in_ha_version=issue["breaks_in_ha_version"],
is_fixable=issue["is_fixable"],
is_persistent=False,
learn_more_url=issue["learn_more_url"],
severity=issue["severity"],
translation_key=issue["translation_key"],
translation_placeholders=issue["translation_placeholders"],
)
await client.send_json({"id": 2, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"issues": [
dict(
issue,
created="2022-07-19T07:53:05+00:00",
dismissed_version=None,
ignored=False,
)
for issue in issues
]
}
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"])
async def test_fix_issue_aborted(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test we can fix an issue."""
assert await async_setup_component(hass, "http", {})
assert await async_setup_component(hass, DOMAIN, {})
ws_client = await hass_ws_client(hass)
client = await hass_client()
await create_issues(
hass,
ws_client,
issues=[
{
**DEFAULT_ISSUES[0],
"domain": "fake_integration",
"issue_id": "abort_issue1",
}
],
)
await ws_client.send_json({"id": 3, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
first_issue = msg["result"]["issues"][0]
assert first_issue["domain"] == "fake_integration"
assert first_issue["issue_id"] == "abort_issue1"
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": "fake_integration", "issue_id": "abort_issue1"},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"type": "abort",
"flow_id": flow_id,
"handler": "fake_integration",
"reason": "not_given",
"description_placeholders": None,
}
await ws_client.send_json({"id": 4, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
assert msg["result"]["issues"][0] == first_issue
@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"])
@pytest.mark.freeze_time("2022-07-19 07:53:05")
async def test_get_issue_data(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test we can get issue data."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
issues = [
{
"breaks_in_ha_version": "2022.9",
"data": None,
"domain": "test",
"is_fixable": True,
"issue_id": "issue_1",
"issue_domain": None,
"learn_more_url": "https://theuselessweb.com",
"severity": "error",
"translation_key": "abc_123",
"translation_placeholders": {"abc": "123"},
},
{
"breaks_in_ha_version": "2022.8",
"data": {"key": "value"},
"domain": "test",
"is_fixable": False,
"issue_id": "issue_2",
"issue_domain": None,
"learn_more_url": "https://theuselessweb.com/abc",
"severity": "other",
"translation_key": "even_worse",
"translation_placeholders": {"def": "456"},
},
]
for issue in issues:
ir.async_create_issue(
hass,
issue["domain"],
issue["issue_id"],
breaks_in_ha_version=issue["breaks_in_ha_version"],
data=issue["data"],
is_fixable=issue["is_fixable"],
is_persistent=False,
learn_more_url=issue["learn_more_url"],
severity=issue["severity"],
translation_key=issue["translation_key"],
translation_placeholders=issue["translation_placeholders"],
)
await client.send_json_auto_id(
{"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_1"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {"issue_data": None}
await client.send_json_auto_id(
{"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_2"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {"issue_data": {"key": "value"}}
await client.send_json_auto_id(
{"type": "repairs/get_issue_data", "domain": "test", "issue_id": "unknown"}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"] == {
"code": "unknown_issue",
"message": "Issue 'unknown' not found",
}