"""Test config flow.""" from collections.abc import Generator from http import HTTPStatus from unittest.mock import AsyncMock, patch from uuid import uuid4 from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import Discovery from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components.hassio import DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.config_entries import ConfigEntries from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, MockModule, MockUser, mock_config_flow, mock_integration, mock_platform, ) @pytest.fixture(name="mock_mqtt") def mock_mqtt_fixture( hass: HomeAssistant, ) -> Generator[type[config_entries.ConfigFlow]]: """Mock the MQTT integration's config flow.""" mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_platform(hass, f"{MQTT_DOMAIN}.config_flow", None) class MqttFlow(config_entries.ConfigFlow): """Test flow.""" VERSION = 1 async_step_hassio = AsyncMock(return_value={"type": "abort"}) with mock_config_flow(MQTT_DOMAIN, MqttFlow): yield MqttFlow @pytest.mark.usefixtures("hassio_client") async def test_hassio_discovery_startup( hass: HomeAssistant, mock_mqtt: type[config_entries.ConfigFlow], addon_installed: AsyncMock, get_addon_discovery_info: AsyncMock, ) -> None: """Test startup and discovery after event.""" get_addon_discovery_info.return_value = [ Discovery( addon="mosquitto", service="mqtt", uuid=(uuid := uuid4()), config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", }, ) ] addon_installed.return_value.name = "Mosquitto Test" assert get_addon_discovery_info.call_count == 0 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert get_addon_discovery_info.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", }, name="Mosquitto Test", slug="mosquitto", uuid=uuid.hex, ) ) @pytest.mark.usefixtures("hassio_client") async def test_hassio_discovery_startup_done( hass: HomeAssistant, mock_mqtt: type[config_entries.ConfigFlow], addon_installed: AsyncMock, get_addon_discovery_info: AsyncMock, supervisor_root_info: AsyncMock, ) -> None: """Test startup and discovery with hass discovery.""" get_addon_discovery_info.return_value = [ Discovery( addon="mosquitto", service="mqtt", uuid=(uuid := uuid4()), config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", }, ) ] addon_installed.return_value.name = "Mosquitto Test" supervisor_root_info.side_effect = SupervisorError() await hass.async_start() await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert get_addon_discovery_info.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", }, name="Mosquitto Test", slug="mosquitto", uuid=uuid.hex, ) ) async def test_hassio_discovery_webhook( hass: HomeAssistant, hassio_client: TestClient, mock_mqtt: type[config_entries.ConfigFlow], addon_installed: AsyncMock, get_discovery_message: AsyncMock, ) -> None: """Test discovery webhook.""" get_discovery_message.return_value = Discovery( addon="mosquitto", service="mqtt", uuid=(uuid := uuid4()), config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", }, ) addon_installed.return_value.name = "Mosquitto Test" resp = await hassio_client.post( f"/api/hassio_push/discovery/{uuid!s}", json={"addon": "mosquitto", "service": "mqtt", "uuid": str(uuid)}, ) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert resp.status == HTTPStatus.OK assert get_discovery_message.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", }, name="Mosquitto Test", slug="mosquitto", uuid=uuid.hex, ) ) async def test_hassio_discovery_webhook_non_admin( hass: HomeAssistant, hassio_client: TestClient, mock_mqtt: type[config_entries.ConfigFlow], addon_installed: AsyncMock, get_discovery_message: AsyncMock, hass_admin_user: MockUser, ) -> None: """Test discovery webhook fails for non-admin users.""" addon_installed.return_value.name = "Mosquitto Test" await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() hass_admin_user.groups = [] get_discovery_message.reset_mock() uuid = uuid4() resp = await hassio_client.post( f"/api/hassio_push/discovery/{uuid!s}", json={"addon": "mosquitto", "service": "mqtt", "uuid": str(uuid)}, ) await hass.async_block_till_done() assert resp.status == HTTPStatus.UNAUTHORIZED get_discovery_message.assert_not_called() mock_mqtt.async_step_hassio.assert_not_called() TEST_UUID = str(uuid4()) @pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") async def test_delete_hassio_discovery( hass: HomeAssistant, get_discovery_message: AsyncMock, hassio_client: TestClient ) -> None: """Test deleting a discovery item removes the config entry.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() entry = MockConfigEntry( domain=MQTT_DOMAIN, discovery_keys={ "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) }, unique_id=(uuid := uuid4()).hex, state=config_entries.ConfigEntryState.LOADED, source=config_entries.SOURCE_HASSIO, ) entry.add_to_hass(hass) get_discovery_message.side_effect = SupervisorNotFoundError() with patch.object(ConfigEntries, "async_remove") as mock_remove: resp = await hassio_client.delete( f"/api/hassio_push/discovery/{uuid.hex}", json={"service": "mqtt", "uuid": uuid.hex}, ) await hass.async_block_till_done() assert resp.status == HTTPStatus.OK get_discovery_message.assert_called_once_with(uuid) mock_remove.assert_called_once_with(entry.entry_id) @pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") async def test_delete_hassio_discovery_fails_when_discovery_exists( hass: HomeAssistant, get_discovery_message: AsyncMock, hassio_client: TestClient, caplog: pytest.LogCaptureFixture, ) -> None: """Test deleting a discovery item fails when discovery exists.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() entry = MockConfigEntry( domain=MQTT_DOMAIN, discovery_keys={ "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) }, unique_id=(uuid := uuid4()).hex, state=config_entries.ConfigEntryState.LOADED, source=config_entries.SOURCE_HASSIO, ) entry.add_to_hass(hass) get_discovery_message.return_value = Discovery( addon="mosquitto", service="mqtt", uuid=(uuid := uuid4()), config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", }, ) with patch.object(ConfigEntries, "async_remove") as mock_remove: resp = await hassio_client.delete( f"/api/hassio_push/discovery/{uuid.hex}", json={"service": "mqtt", "uuid": uuid.hex}, ) await hass.async_block_till_done() assert resp.status == HTTPStatus.OK get_discovery_message.assert_called_once_with(uuid) mock_remove.assert_not_called() assert "Retrieve wrong unload for mqtt" in caplog.text @pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") async def test_delete_hassio_discovery_non_admin( hass: HomeAssistant, get_discovery_message: AsyncMock, hassio_client: TestClient, hass_admin_user: MockUser, ) -> None: """Test deleting a discovery item fails for non-admin users.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() entry = MockConfigEntry( domain=MQTT_DOMAIN, discovery_keys={ "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),) }, unique_id=(uuid := uuid4()).hex, state=config_entries.ConfigEntryState.LOADED, source=config_entries.SOURCE_HASSIO, ) entry.add_to_hass(hass) hass_admin_user.groups = [] with patch.object(ConfigEntries, "async_remove") as mock_remove: resp = await hassio_client.delete( f"/api/hassio_push/discovery/{uuid.hex}", json={"service": "mqtt", "uuid": uuid.hex}, ) await hass.async_block_till_done() assert resp.status == HTTPStatus.UNAUTHORIZED get_discovery_message.assert_not_called() mock_remove.assert_not_called() @pytest.mark.parametrize( ( "entry_domain", "entry_discovery_keys", ), [ # Matching discovery key ( "mock-domain", {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), # Matching discovery key ( "mock-domain", { "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),), "other": (DiscoveryKey(domain="other", key="blah", version=1),), }, ), # Matching discovery key, other domain # Note: Rediscovery is not currently restricted to the domain of the removed # entry. Such a check can be added if needed. ( "comp", {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), ], ) @pytest.mark.parametrize( "entry_source", [ config_entries.SOURCE_HASSIO, config_entries.SOURCE_IGNORE, config_entries.SOURCE_USER, ], ) @pytest.mark.usefixtures("hassio_client", "addon_installed", "get_addon_discovery_info") async def test_hassio_rediscover( hass: HomeAssistant, entry_domain: str, entry_discovery_keys: dict[str, tuple[DiscoveryKey, ...]], entry_source: str, get_discovery_message: AsyncMock, ) -> None: """Test we reinitiate flows when an ignored config entry is removed.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() entry = MockConfigEntry( domain=entry_domain, discovery_keys=entry_discovery_keys, unique_id="mock-unique-id", state=config_entries.ConfigEntryState.LOADED, source=entry_source, ) entry.add_to_hass(hass) get_discovery_message.return_value = Discovery( addon="mosquitto", service="mqtt", uuid=(uuid := uuid4()), config={ "broker": "mock-broker", "port": 1883, "username": "mock-user", "password": "mock-pass", "protocol": "3.1.1", }, ) expected_context = { "discovery_key": DiscoveryKey(domain="hassio", key=uuid.hex, version=1), "source": config_entries.SOURCE_HASSIO, } with patch.object(hass.config_entries.flow, "async_init") as mock_init: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mqtt" assert mock_init.mock_calls[0][2]["context"] == expected_context @pytest.mark.usefixtures("mock_async_zeroconf") @pytest.mark.parametrize( ( "entry_domain", "entry_discovery_keys", "entry_source", "entry_unique_id", ), [ # Discovery key from other domain ( "mock-domain", {"bluetooth": (DiscoveryKey(domain="bluetooth", key="test", version=1),)}, config_entries.SOURCE_IGNORE, "mock-unique-id", ), # Discovery key from the future ( "mock-domain", {"hassio": (DiscoveryKey(domain="hassio", key="test", version=2),)}, config_entries.SOURCE_IGNORE, "mock-unique-id", ), ], ) async def test_hassio_rediscover_no_match( hass: HomeAssistant, hassio_client: TestClient, entry_domain: str, entry_discovery_keys: dict[str, tuple[DiscoveryKey, ...]], entry_source: str, entry_unique_id: str, ) -> None: """Test we don't reinitiate flows when a non matching config entry is removed.""" mock_integration(hass, MockModule(entry_domain)) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() entry = MockConfigEntry( domain=entry_domain, discovery_keys=entry_discovery_keys, unique_id=entry_unique_id, state=config_entries.ConfigEntryState.LOADED, source=entry_source, ) entry.add_to_hass(hass) with patch.object(hass.config_entries.flow, "async_init") as mock_init: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0