Add ability to re-auth Notion (#55616)

This commit is contained in:
Aaron Bach 2021-09-24 12:23:19 -06:00 committed by GitHub
parent a7d56d1c3f
commit 0ea5f25594
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 186 additions and 64 deletions

View file

@ -11,7 +11,7 @@ from aionotion.errors import InvalidCredentialsError, NotionError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import (
aiohttp_client, aiohttp_client,
config_validation as cv, config_validation as cv,
@ -52,12 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client = await async_get_client( client = await async_get_client(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
) )
except InvalidCredentialsError: except InvalidCredentialsError as err:
LOGGER.error("Invalid username and/or password") raise ConfigEntryAuthFailed("Invalid username and/or password") from err
return False
except NotionError as err: except NotionError as err:
LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady("Config entry failed to load") from err
raise ConfigEntryNotReady from err
async def async_update() -> dict[str, dict[str, Any]]: async def async_update() -> dict[str, dict[str, Any]]:
"""Get the latest data from the Notion API.""" """Get the latest data from the Notion API."""
@ -70,14 +68,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
results = await asyncio.gather(*tasks.values(), return_exceptions=True) results = await asyncio.gather(*tasks.values(), return_exceptions=True)
for attr, result in zip(tasks, results): for attr, result in zip(tasks, results):
if isinstance(result, InvalidCredentialsError):
raise ConfigEntryAuthFailed(
"Invalid username and/or password"
) from result
if isinstance(result, NotionError): if isinstance(result, NotionError):
raise UpdateFailed( raise UpdateFailed(
f"There was a Notion error while updating {attr}: {result}" f"There was a Notion error while updating {attr}: {result}"
) ) from result
if isinstance(result, Exception): if isinstance(result, Exception):
raise UpdateFailed( raise UpdateFailed(
f"There was an unknown error while updating {attr}: {result}" f"There was an unknown error while updating {attr}: {result}"
) ) from result
for item in result: for item in result:
if attr == "bridges" and item["id"] not in data["bridges"]: if attr == "bridges" and item["id"] not in data["bridges"]:

View file

@ -1,16 +1,31 @@
"""Config flow to configure the Notion integration.""" """Config flow to configure the Notion integration."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any
from aionotion import async_get_client from aionotion import async_get_client
from aionotion.errors import NotionError from aionotion.errors import InvalidCredentialsError, NotionError
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import DOMAIN, LOGGER
AUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
RE_AUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -20,33 +35,77 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self.data_schema = vol.Schema( self._password: str | None = None
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} self._username: str | None = None
)
async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: async def _async_verify(self, step_id: str, schema: vol.Schema) -> FlowResult:
"""Show the form to the user.""" """Attempt to authenticate the provided credentials."""
return self.async_show_form( if TYPE_CHECKING:
step_id="user", data_schema=self.data_schema, errors=errors or {} assert self._username
) assert self._password
session = aiohttp_client.async_get_clientsession(self.hass)
try:
await async_get_client(self._username, self._password, session=session)
except InvalidCredentialsError:
return self.async_show_form(
step_id=step_id,
data_schema=schema,
errors={"base": "invalid_auth"},
description_placeholders={CONF_USERNAME: self._username},
)
except NotionError as err:
LOGGER.error("Unknown Notion error: %s", err)
return self.async_show_form(
step_id=step_id,
data_schema=schema,
errors={"base": "unknown"},
description_placeholders={CONF_USERNAME: self._username},
)
data = {CONF_USERNAME: self._username, CONF_PASSWORD: self._password}
if existing_entry := await self.async_set_unique_id(self._username):
self.hass.config_entries.async_update_entry(existing_entry, data=data)
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self._username, data=data)
async def async_step_reauth(self, config: ConfigType) -> FlowResult:
"""Handle configuration by re-auth."""
self._username = config[CONF_USERNAME]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle re-auth completion."""
if not user_input:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=RE_AUTH_SCHEMA,
description_placeholders={CONF_USERNAME: self._username},
)
self._password = user_input[CONF_PASSWORD]
return await self._async_verify("reauth_confirm", RE_AUTH_SCHEMA)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle the start of the config flow.""" """Handle the start of the config flow."""
if not user_input: if not user_input:
return await self._show_form() return self.async_show_form(step_id="user", data_schema=AUTH_SCHEMA)
await self.async_set_unique_id(user_input[CONF_USERNAME]) await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
session = aiohttp_client.async_get_clientsession(self.hass) self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
try: return await self._async_verify("user", AUTH_SCHEMA)
await async_get_client(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=session
)
except NotionError:
return await self._show_form({"base": "invalid_auth"})
return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input)

View file

@ -1,6 +1,13 @@
{ {
"config": { "config": {
"step": { "step": {
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Please re-enter the password for {username}.",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
},
"user": { "user": {
"title": "Fill in your information", "title": "Fill in your information",
"data": { "data": {
@ -11,10 +18,11 @@
}, },
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No devices found in account" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]" "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
} }
} }

View file

@ -1,13 +1,21 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Account is already configured" "already_configured": "Account is already configured",
"reauth_successful": "Re-authentication was successful"
}, },
"error": { "error": {
"invalid_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
"no_devices": "No devices found in account" "unknown": "Unexpected error"
}, },
"step": { "step": {
"reauth_confirm": {
"data": {
"password": "Password"
},
"description": "Please re-enter the password for {username}.",
"title": "Reauthenticate Integration"
},
"user": { "user": {
"data": { "data": {
"password": "Password", "password": "Password",

View file

@ -1,29 +1,31 @@
"""Define tests for the Notion config flow.""" """Define tests for the Notion config flow."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aionotion from aionotion.errors import InvalidCredentialsError, NotionError
import pytest import pytest
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.components.notion import DOMAIN, config_flow from homeassistant.components.notion import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture @pytest.fixture(name="client")
def mock_client(): def client_fixture():
"""Define a fixture for a client creation coroutine.""" """Define a fixture for an aionotion client."""
return AsyncMock(return_value=None) return AsyncMock(return_value=None)
@pytest.fixture @pytest.fixture(name="client_login")
def mock_aionotion(mock_client): def client_login_fixture(client):
"""Mock the aionotion library.""" """Define a fixture for patching the aiowatttime coroutine to get a client."""
with patch("homeassistant.components.notion.config_flow.async_get_client") as mock_: with patch(
mock_.side_effect = mock_client "homeassistant.components.notion.config_flow.async_get_client"
yield mock_ ) as mock_client:
mock_client.side_effect = client
yield mock_client
async def test_duplicate_error(hass): async def test_duplicate_error(hass):
@ -37,47 +39,90 @@ async def test_duplicate_error(hass):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf DOMAIN, context={"source": SOURCE_USER}, data=conf
) )
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
@pytest.mark.parametrize( @pytest.mark.parametrize("client", [AsyncMock(side_effect=NotionError)])
"mock_client", [AsyncMock(side_effect=aionotion.errors.NotionError)] async def test_generic_notion_error(client_login, hass):
) """Test that a generic aionotion error is handled correctly."""
async def test_invalid_credentials(hass, mock_aionotion):
"""Test that an invalid API/App Key throws an error."""
conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}
flow = config_flow.NotionFlowHandler() result = await hass.config_entries.flow.async_init(
flow.hass = hass DOMAIN, context={"source": SOURCE_USER}, data=conf
flow.context = {"source": SOURCE_USER} )
assert result["errors"] == {"base": "unknown"}
@pytest.mark.parametrize("client", [AsyncMock(side_effect=InvalidCredentialsError)])
async def test_invalid_credentials(client_login, hass):
"""Test that invalid credentials throw an error."""
conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
)
await hass.async_block_till_done()
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_auth"} assert result["errors"] == {"base": "invalid_auth"}
async def test_show_form(hass): async def test_step_reauth(client_login, hass):
"""Test that the form is served with no input.""" """Test that the reauth step works."""
flow = config_flow.NotionFlowHandler() MockConfigEntry(
flow.hass = hass domain=DOMAIN,
flow.context = {"source": SOURCE_USER} unique_id="user@email.com",
data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"},
).add_to_hass(hass)
result = await flow.async_step_user(user_input=None) result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"},
)
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.notion.async_setup_entry", return_value=True
), patch("homeassistant.config_entries.ConfigEntries.async_reload"):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "password"}
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful"
assert len(hass.config_entries.async_entries()) == 1
async def test_show_form(client_login, hass):
"""Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
async def test_step_user(hass, mock_aionotion): async def test_step_user(client_login, hass):
"""Test that the user step works.""" """Test that the user step works."""
conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}
flow = config_flow.NotionFlowHandler() with patch("homeassistant.components.notion.async_setup_entry", return_value=True):
flow.hass = hass result = await hass.config_entries.flow.async_init(
flow.context = {"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}, data=conf
)
await hass.async_block_till_done()
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "user@host.com" assert result["title"] == "user@host.com"
assert result["data"] == { assert result["data"] == {