diff --git a/.strict-typing b/.strict-typing index ce04c74702b..caaef80fe38 100644 --- a/.strict-typing +++ b/.strict-typing @@ -127,6 +127,7 @@ homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* +homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index bf9b88d5eec..7b872ade7ff 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -528,6 +528,7 @@ homeassistant/components/system_bridge/* @timmo001 homeassistant/components/tado/* @michaelarnauts @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei +homeassistant/components/tailscale/* @frenck homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tapsaff/* @bazwilliams homeassistant/components/tasmota/* @emontnemery diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py new file mode 100644 index 00000000000..f5e1c59b222 --- /dev/null +++ b/homeassistant/components/tailscale/__init__.py @@ -0,0 +1,30 @@ +"""The Tailscale integration.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TailscaleDataUpdateCoordinator + +PLATFORMS = (BINARY_SENSOR_DOMAIN,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tailscale from a config entry.""" + coordinator = TailscaleDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tailscale config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py new file mode 100644 index 00000000000..37a25055bfc --- /dev/null +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -0,0 +1,112 @@ +"""Support for Tailscale binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tailscale import Device as TailscaleDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +@dataclass +class TailscaleBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + is_on_fn: Callable[[TailscaleDevice], bool | None] + + +@dataclass +class TailscaleBinarySensorEntityDescription( + BinarySensorEntityDescription, TailscaleBinarySensorEntityDescriptionMixin +): + """Describes a Tailscale binary sensor entity.""" + + +BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( + TailscaleBinarySensorEntityDescription( + key="update_available", + name="Client", + device_class=BinarySensorDeviceClass.UPDATE, + is_on_fn=lambda device: device.update_available, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Tailscale binary sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailscaleBinarySensorEntity( + coordinator=coordinator, + device=device, + description=description, + ) + for device in coordinator.data.values() + for description in BINARY_SENSORS + ) + + +class TailscaleBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): + """Defines a Tailscale binary sensor.""" + + entity_description: TailscaleBinarySensorEntityDescription + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + device: TailscaleDevice, + description: TailscaleBinarySensorEntityDescription, + ) -> None: + """Initialize a Tailscale binary sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self.device_id = device.device_id + self._attr_name = f"{device.hostname} {description.name}" + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device: TailscaleDevice = self.coordinator.data[self.device_id] + + configuration_url = "https://login.tailscale.com/admin/machines/" + if device.addresses: + configuration_url += device.addresses[0] + + return DeviceInfo( + configuration_url=configuration_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Tailscale Inc.", + model=device.os, + name=device.hostname, + sw_version=device.client_version, + ) + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return bool( + self.entity_description.is_on_fn(self.coordinator.data[self.device_id]) + ) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py new file mode 100644 index 00000000000..cda4020a290 --- /dev/null +++ b/homeassistant/components/tailscale/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow to configure the Tailscale integration.""" +from __future__ import annotations + +from typing import Any + +from tailscale import Tailscale, TailscaleAuthenticationError, TailscaleError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_TAILNET, DOMAIN + + +async def validate_input(hass: HomeAssistant, *, tailnet: str, api_key: str) -> None: + """Try using the give tailnet & api key against the Tailscale API.""" + session = async_get_clientsession(hass) + tailscale = Tailscale( + session=session, + api_key=api_key, + tailnet=tailnet, + ) + await tailscale.devices() + + +class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Tailscale.""" + + VERSION = 1 + + reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await validate_input( + self.hass, + tailnet=user_input[CONF_TAILNET], + api_key=user_input[CONF_API_KEY], + ) + except TailscaleAuthenticationError: + errors["base"] = "invalid_auth" + except TailscaleError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(user_input[CONF_TAILNET]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_TAILNET], + data={ + CONF_TAILNET: user_input[CONF_TAILNET], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_TAILNET, default=user_input.get(CONF_TAILNET, "") + ): str, + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Tailscale.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with Tailscale.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + await validate_input( + self.hass, + tailnet=self.reauth_entry.data[CONF_TAILNET], + api_key=user_input[CONF_API_KEY], + ) + except TailscaleAuthenticationError: + errors["base"] = "invalid_auth" + except TailscaleError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/tailscale/const.py b/homeassistant/components/tailscale/const.py new file mode 100644 index 00000000000..7cdf0cddf71 --- /dev/null +++ b/homeassistant/components/tailscale/const.py @@ -0,0 +1,13 @@ +"""Constants for the Tailscale integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "tailscale" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=1) + +CONF_TAILNET: Final = "tailnet" diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py new file mode 100644 index 00000000000..daebfe807c1 --- /dev/null +++ b/homeassistant/components/tailscale/coordinator.py @@ -0,0 +1,39 @@ +"""DataUpdateCoordinator for the Tailscale integration.""" +from __future__ import annotations + +from tailscale import Device, Tailscale, TailscaleAuthenticationError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_TAILNET, DOMAIN, LOGGER, SCAN_INTERVAL + + +class TailscaleDataUpdateCoordinator(DataUpdateCoordinator): + """The Tailscale Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Tailscale coordinator.""" + self.config_entry = entry + + session = async_get_clientsession(hass) + self.tailscale = Tailscale( + session=session, + api_key=entry.data[CONF_API_KEY], + tailnet=entry.data[CONF_TAILNET], + ) + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch devices from Tailscale.""" + try: + return await self.tailscale.devices() + except TailscaleAuthenticationError as err: + raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json new file mode 100644 index 00000000000..973ae420d40 --- /dev/null +++ b/homeassistant/components/tailscale/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tailscale", + "name": "Tailscale", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tailscale", + "requirements": ["tailscale==0.1.2"], + "codeowners": ["@frenck"], + "quality_scale": "platinum", + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json new file mode 100644 index 00000000000..247d6032c03 --- /dev/null +++ b/homeassistant/components/tailscale/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "To authenticate with Tailscale you'll need to create an API key at https://login.tailscale.com/admin/settings/authkeys.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo).", + "data": { + "tailnet": "Tailnet", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "description":"Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/tailscale/translations/en.json b/homeassistant/components/tailscale/translations/en.json new file mode 100644 index 00000000000..f1e79785cbf --- /dev/null +++ b/homeassistant/components/tailscale/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + }, + "description": "Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys." + }, + "user": { + "data": { + "api_key": "API Key", + "tailnet": "Tailnet" + }, + "description": "To authenticate with Tailscale you'll need to create an API key at https://login.tailscale.com/admin/settings/authkeys.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 79e65b96234..c2648ec04cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -295,6 +295,7 @@ FLOWS = [ "synology_dsm", "system_bridge", "tado", + "tailscale", "tasmota", "tellduslive", "tesla_wall_connector", diff --git a/mypy.ini b/mypy.ini index cc1337bfc3c..483b8bd22f1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1408,6 +1408,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tailscale.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 93e7c008cf1..0a65c414efd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2274,6 +2274,9 @@ systembridge==2.2.3 # homeassistant.components.tahoma tahoma-api==0.0.16 +# homeassistant.components.tailscale +tailscale==0.1.2 + # homeassistant.components.tank_utility tank_utility==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 718047891d7..cca40724fa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1351,6 +1351,9 @@ surepy==0.7.2 # homeassistant.components.system_bridge systembridge==2.2.3 +# homeassistant.components.tailscale +tailscale==0.1.2 + # homeassistant.components.tellduslive tellduslive==0.10.11 diff --git a/tests/components/tailscale/__init__.py b/tests/components/tailscale/__init__.py new file mode 100644 index 00000000000..cdae3b16d0b --- /dev/null +++ b/tests/components/tailscale/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tailscale integration.""" diff --git a/tests/components/tailscale/conftest.py b/tests/components/tailscale/conftest.py new file mode 100644 index 00000000000..12f11a5656d --- /dev/null +++ b/tests/components/tailscale/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for Tailscale integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from tailscale.models import Devices + +from homeassistant.components.tailscale.const import CONF_TAILNET, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="homeassistant.github", + domain=DOMAIN, + data={CONF_TAILNET: "homeassistant.github", CONF_API_KEY: "tskey-MOCK"}, + unique_id="homeassistant.github", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tailscale.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Tailscale client.""" + with patch( + "homeassistant.components.tailscale.config_flow.Tailscale", autospec=True + ) as tailscale_mock: + tailscale = tailscale_mock.return_value + tailscale.devices.return_value = Devices.parse_raw( + load_fixture("tailscale/devices.json") + ).devices + yield tailscale + + +@pytest.fixture +def mock_tailscale(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Tailscale client.""" + fixture: str = "tailscale/devices.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + devices = Devices.parse_raw(load_fixture(fixture)).devices + with patch( + "homeassistant.components.tailscale.coordinator.Tailscale", autospec=True + ) as tailscale_mock: + tailscale = tailscale_mock.return_value + tailscale.devices.return_value = devices + yield tailscale + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tailscale: MagicMock +) -> MockConfigEntry: + """Set up the Tailscale integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json new file mode 100644 index 00000000000..1d7ea756399 --- /dev/null +++ b/tests/components/tailscale/fixtures/devices.json @@ -0,0 +1,127 @@ +{ + "devices": [ + { + "addresses": [ + "100.11.11.111" + ], + "id": "123456", + "user": "frenck", + "name": "frencks-iphone.homeassistant.github", + "hostname": "Frencks-iPhone", + "clientVersion": "1.12.3-td91ea7286-ge1bbbd90c", + "updateAvailable": true, + "os": "iOS", + "created": "2021-08-19T09:25:22Z", + "lastSeen": "2021-09-16T06:11:23Z", + "keyExpiryDisabled": false, + "expires": "2022-02-15T09:25:22Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": [], + "advertisedRoutes": [], + "clientConnectivity": { + "endpoints": [ + "192.0.0.1:41641", + "192.168.11.154:41641" + ], + "derp": "", + "mappingVariesByDestIP": false, + "latency": {}, + "clientSupports": { + "hairPinning": false, + "ipv6": false, + "pcp": false, + "pmp": false, + "udp": true, + "upnp": false + } + } + }, + { + "addresses": [ + "100.11.11.111" + ], + "id": "123457", + "user": "frenck", + "name": "router.homeassistant.github", + "hostname": "router", + "clientVersion": "1.14.0-t5cff36945-g809e87bba", + "updateAvailable": true, + "os": "linux", + "created": "2021-08-29T09:49:06Z", + "lastSeen": "2021-11-15T20:37:03Z", + "keyExpiryDisabled": false, + "expires": "2022-02-25T09:49:06Z", + "authorized": true, + "isExternal": false, + "machineKey": "mkey:mock", + "nodeKey": "nodekey:mock", + "blocksIncomingConnections": false, + "enabledRoutes": [ + "0.0.0.0/0", + "10.10.10.0/23", + "::/0" + ], + "advertisedRoutes": [ + "0.0.0.0/0", + "10.10.10.0/23", + "::/0" + ], + "clientConnectivity": { + "endpoints": [ + "10.10.10.1:41641", + "111.111.111.111:41641" + ], + "derp": "", + "mappingVariesByDestIP": false, + "latency": { + "Bangalore": { + "latencyMs": 143.42505599999998 + }, + "Chicago": { + "latencyMs": 101.123646 + }, + "Dallas": { + "latencyMs": 136.85886 + }, + "Frankfurt": { + "latencyMs": 18.968314 + }, + "London": { + "preferred": true, + "latencyMs": 14.314574 + }, + "New York City": { + "latencyMs": 83.078912 + }, + "San Francisco": { + "latencyMs": 148.215522 + }, + "Seattle": { + "latencyMs": 181.553595 + }, + "Singapore": { + "latencyMs": 164.566539 + }, + "São Paulo": { + "latencyMs": 207.250179 + }, + "Tokyo": { + "latencyMs": 226.90714300000002 + } + }, + "clientSupports": { + "hairPinning": true, + "ipv6": false, + "pcp": false, + "pmp": false, + "udp": true, + "upnp": false + } + } + } + ] +} \ No newline at end of file diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py new file mode 100644 index 00000000000..e3d0c9c9348 --- /dev/null +++ b/tests/components/tailscale/test_binary_sensor.py @@ -0,0 +1,41 @@ +"""Tests for the sensors provided by the Tailscale integration.""" +from homeassistant.components.binary_sensor import STATE_ON, BinarySensorDeviceClass +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_tailscale_binary_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Tailscale binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.frencks_iphone_client") + entry = entity_registry.async_get("binary_sensor.frencks_iphone_client") + assert entry + assert state + assert entry.unique_id == "123456_update_available" + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Client" + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "123456")} + assert device_entry.manufacturer == "Tailscale Inc." + assert device_entry.model == "iOS" + assert device_entry.name == "Frencks-iPhone" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + assert device_entry.sw_version == "1.12.3-td91ea7286-ge1bbbd90c" + assert ( + device_entry.configuration_url + == "https://login.tailscale.com/admin/machines/100.11.11.111" + ) diff --git a/tests/components/tailscale/test_config_flow.py b/tests/components/tailscale/test_config_flow.py new file mode 100644 index 00000000000..eb070cfdbb2 --- /dev/null +++ b/tests/components/tailscale/test_config_flow.py @@ -0,0 +1,257 @@ +"""Tests for the Tailscale config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from tailscale import TailscaleAuthenticationError, TailscaleConnectionError + +from homeassistant.components.tailscale.const import CONF_TAILNET, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "homeassistant.github" + assert result2.get("data") == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow with incorrect API key. + + This tests tests a full config flow, with a case the user enters an invalid + Tailscale API key, but recovers by entering the correct one. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-INVALID", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + mock_tailscale_config_flow.devices.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "homeassistant.github" + assert result3.get("data") == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 2 + + +async def test_connection_error( + hass: HomeAssistant, mock_tailscale_config_flow: MagicMock +) -> None: + """Test API connection error.""" + mock_tailscale_config_flow.devices.side_effect = TailscaleConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-FAKE", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-REAUTH"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-REAUTH", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + +async def test_reauth_with_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow with an authentication error. + + This tests tests a reauth flow, with a case the user enters an invalid + API key, but recover by entering the correct one. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-INVALID"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 1 + + mock_tailscale_config_flow.devices.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: "tskey-VALID"}, + ) + await hass.async_block_till_done() + + assert result3.get("type") == RESULT_TYPE_ABORT + assert result3.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_TAILNET: "homeassistant.github", + CONF_API_KEY: "tskey-VALID", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_tailscale_config_flow.devices.mock_calls) == 2 + + +async def test_reauth_api_error( + hass: HomeAssistant, + mock_tailscale_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test API error during reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_tailscale_config_flow.devices.side_effect = TailscaleConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "tskey-VALID"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/tailscale/test_init.py b/tests/components/tailscale/test_init.py new file mode 100644 index 00000000000..11ca8a910a6 --- /dev/null +++ b/tests/components/tailscale/test_init.py @@ -0,0 +1,72 @@ +"""Tests for the Tailscale integration.""" +from unittest.mock import MagicMock + +from tailscale import TailscaleAuthenticationError, TailscaleConnectionError + +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test the Tailscale configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test the Tailscale configuration entry not ready.""" + mock_tailscale.devices.side_effect = TailscaleConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tailscale.devices.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailscale: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailscale.devices.side_effect = TailscaleAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id