Add Tailscale integration (#59764)

* Add Tailscale integration

* Use DeviceEntryType

* Fix tests

* Adjust to new Pylint version

* Use enums for device classes

* Update homeassistant/components/tailscale/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Pass empty string as default

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Franck Nijhof 2021-12-01 14:40:38 +01:00 committed by GitHub
parent 59f87b9488
commit 6a8c732b37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 973 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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])
)

View File

@ -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,
)

View File

@ -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"

View File

@ -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

View File

@ -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"
}

View File

@ -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%]"
}
}
}

View File

@ -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)."
}
}
}
}

View File

@ -295,6 +295,7 @@ FLOWS = [
"synology_dsm",
"system_bridge",
"tado",
"tailscale",
"tasmota",
"tellduslive",
"tesla_wall_connector",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Tailscale integration."""

View File

@ -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

View File

@ -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
}
}
}
]
}

View File

@ -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"
)

View File

@ -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"}

View File

@ -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