Add re-auth flow to NextDNS integration (#125101)

This commit is contained in:
Maciej Bieniek 2024-09-03 22:38:07 +02:00 committed by GitHub
parent cc3d059783
commit 50c1bf8bb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 202 additions and 17 deletions

View File

@ -15,6 +15,7 @@ from nextdns import (
AnalyticsStatus, AnalyticsStatus,
ApiError, ApiError,
ConnectionStatus, ConnectionStatus,
InvalidApiKeyError,
NextDns, NextDns,
Settings, Settings,
) )
@ -23,7 +24,7 @@ from tenacity import RetryError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( from .const import (
@ -88,6 +89,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b
nextdns = await NextDns.create(websession, api_key) nextdns = await NextDns.create(websession, api_key)
except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed from err
tasks = [] tasks = []
coordinators = {} coordinators = {}

View File

@ -2,19 +2,30 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from nextdns import ApiError, InvalidApiKeyError, NextDns from nextdns import ApiError, InvalidApiKeyError, NextDns
from tenacity import RetryError from tenacity import RetryError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PROFILE_ID, DOMAIN from .const import CONF_PROFILE_ID, DOMAIN
AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
"""Check if credentials are valid."""
websession = async_get_clientsession(hass)
return await NextDns.create(websession, api_key)
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for NextDNS.""" """Config flow for NextDNS."""
@ -23,8 +34,9 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self.nextdns: NextDns | None = None self.nextdns: NextDns
self.api_key: str | None = None self.api_key: str
self.entry: ConfigEntry | None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -32,14 +44,10 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
websession = async_get_clientsession(self.hass)
if user_input is not None: if user_input is not None:
self.api_key = user_input[CONF_API_KEY] self.api_key = user_input[CONF_API_KEY]
try: try:
self.nextdns = await NextDns.create( self.nextdns = await async_init_nextdns(self.hass, self.api_key)
websession, user_input[CONF_API_KEY]
)
except InvalidApiKeyError: except InvalidApiKeyError:
errors["base"] = "invalid_api_key" errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError): except (ApiError, ClientConnectorError, RetryError, TimeoutError):
@ -51,7 +59,7 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), data_schema=AUTH_SCHEMA,
errors=errors, errors=errors,
) )
@ -61,8 +69,6 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle the profiles step.""" """Handle the profiles step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
assert self.nextdns is not None
if user_input is not None: if user_input is not None:
profile_name = user_input[CONF_PROFILE_NAME] profile_name = user_input[CONF_PROFILE_NAME]
profile_id = self.nextdns.get_profile_id(profile_name) profile_id = self.nextdns.get_profile_id(profile_name)
@ -86,3 +92,39 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
), ),
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self.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
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
if user_input is not None:
try:
await async_init_nextdns(self.hass, user_input[CONF_API_KEY])
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
errors["base"] = "unknown"
else:
if TYPE_CHECKING:
assert self.entry is not None
return self.async_update_reload_and_abort(
self.entry, data={**self.entry.data, **user_input}
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=AUTH_SCHEMA,
errors=errors,
)

View File

@ -21,6 +21,7 @@ from nextdns.model import NextDnsData
from tenacity import RetryError from tenacity import RetryError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -62,10 +63,11 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]):
except ( except (
ApiError, ApiError,
ClientConnectorError, ClientConnectorError,
InvalidApiKeyError,
RetryError, RetryError,
) as err: ) as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err
except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed from err
async def _async_update_data_internal(self) -> CoordinatorDataT: async def _async_update_data_internal(self) -> CoordinatorDataT:
"""Update data via library.""" """Update data via library."""

View File

@ -10,6 +10,11 @@
"data": { "data": {
"profile": "Profile" "profile": "Profile"
} }
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
} }
}, },
"error": { "error": {
@ -18,7 +23,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "This NextDNS profile is already configured." "already_configured": "This NextDNS profile is already configured.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"system_health": { "system_health": {

View File

@ -101,3 +101,59 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
async def test_reauth_successful(hass: HomeAssistant) -> None:
"""Test starting a reauthentication flow."""
entry = await init_integration(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with (
patch(
"homeassistant.components.nextdns.NextDns.get_profiles",
return_value=PROFILES,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new_api_key"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@pytest.mark.parametrize(
("exc", "base_error"),
[
(ApiError("API Error"), "cannot_connect"),
(InvalidApiKeyError, "invalid_api_key"),
(RetryError("Retry Error"), "cannot_connect"),
(TimeoutError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_reauth_errors(
hass: HomeAssistant, exc: Exception, base_error: str
) -> None:
"""Test reauthentication flow with errors."""
entry = await init_integration(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new_api_key"},
)
await hass.async_block_till_done()
assert result["errors"] == {"base": base_error}

View File

@ -0,0 +1,46 @@
"""Tests for NextDNS coordinator."""
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from nextdns import InvalidApiKeyError
from homeassistant.components.nextdns.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from . import init_integration
from tests.common import async_fire_time_changed
async def test_auth_error(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test authentication error when polling data."""
entry = await init_integration(hass)
assert entry.state is ConfigEntryState.LOADED
freezer.tick(timedelta(minutes=10))
with patch(
"homeassistant.components.nextdns.NextDns.connection_status",
side_effect=InvalidApiKeyError,
):
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
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") == entry.entry_id

View File

@ -2,12 +2,12 @@
from unittest.mock import patch from unittest.mock import patch
from nextdns import ApiError from nextdns import ApiError, InvalidApiKeyError
import pytest import pytest
from tenacity import RetryError from tenacity import RetryError
from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -59,3 +59,33 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
assert entry.state is ConfigEntryState.NOT_LOADED assert entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN) assert not hass.data.get(DOMAIN)
async def test_config_auth_failed(hass: HomeAssistant) -> None:
"""Test for setup failure if the auth fails."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Fake Profile",
unique_id="xyz12",
data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.nextdns.NextDns.get_profiles",
side_effect=InvalidApiKeyError,
):
await hass.config_entries.async_setup(entry.entry_id)
assert 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") == entry.entry_id