diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index c06a08560e9..c9f4131be1a 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -11,7 +11,7 @@ from aionotion.errors import InvalidCredentialsError, NotionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, @@ -52,12 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = await async_get_client( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) - except InvalidCredentialsError: - LOGGER.error("Invalid username and/or password") - return False + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed("Invalid username and/or password") from err except NotionError as err: - LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady("Config entry failed to load") from err async def async_update() -> dict[str, dict[str, Any]]: """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) for attr, result in zip(tasks, results): + if isinstance(result, InvalidCredentialsError): + raise ConfigEntryAuthFailed( + "Invalid username and/or password" + ) from result if isinstance(result, NotionError): raise UpdateFailed( f"There was a Notion error while updating {attr}: {result}" - ) + ) from result if isinstance(result, Exception): raise UpdateFailed( f"There was an unknown error while updating {attr}: {result}" - ) + ) from result for item in result: if attr == "bridges" and item["id"] not in data["bridges"]: diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index ad6d8eb9519..84fe69eb61a 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,16 +1,31 @@ """Config flow to configure the Notion integration.""" from __future__ import annotations +from typing import TYPE_CHECKING, Any + from aionotion import async_get_client -from aionotion.errors import NotionError +from aionotion.errors import InvalidCredentialsError, NotionError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult 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): @@ -20,33 +35,77 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self.data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ) + self._password: str | None = None + self._username: str | None = None - async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: - """Show the form to the user.""" - return self.async_show_form( - step_id="user", data_schema=self.data_schema, errors=errors or {} - ) + async def _async_verify(self, step_id: str, schema: vol.Schema) -> FlowResult: + """Attempt to authenticate the provided credentials.""" + if TYPE_CHECKING: + 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( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the start of the config flow.""" 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]) 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: - 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) + return await self._async_verify("user", AUTH_SCHEMA) diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 401f0095e30..49721568ff2 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -1,6 +1,13 @@ { "config": { "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": { "title": "Fill in your information", "data": { @@ -11,10 +18,11 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_devices": "No devices found in account" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "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%]" } } } diff --git a/homeassistant/components/notion/translations/en.json b/homeassistant/components/notion/translations/en.json index a31befc0e95..0eb4689bce6 100644 --- a/homeassistant/components/notion/translations/en.json +++ b/homeassistant/components/notion/translations/en.json @@ -1,13 +1,21 @@ { "config": { "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", - "no_devices": "No devices found in account" + "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Please re-enter the password for {username}.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "password": "Password", diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index d9ed37d516c..bda70bd6af9 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -1,29 +1,31 @@ """Define tests for the Notion config flow.""" from unittest.mock import AsyncMock, patch -import aionotion +from aionotion.errors import InvalidCredentialsError, NotionError import pytest from homeassistant import data_entry_flow -from homeassistant.components.notion import DOMAIN, config_flow -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.notion import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for a client creation coroutine.""" +@pytest.fixture(name="client") +def client_fixture(): + """Define a fixture for an aionotion client.""" return AsyncMock(return_value=None) -@pytest.fixture -def mock_aionotion(mock_client): - """Mock the aionotion library.""" - with patch("homeassistant.components.notion.config_flow.async_get_client") as mock_: - mock_.side_effect = mock_client - yield mock_ +@pytest.fixture(name="client_login") +def client_login_fixture(client): + """Define a fixture for patching the aiowatttime coroutine to get a client.""" + with patch( + "homeassistant.components.notion.config_flow.async_get_client" + ) as mock_client: + mock_client.side_effect = client + yield mock_client async def test_duplicate_error(hass): @@ -37,47 +39,90 @@ async def test_duplicate_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=aionotion.errors.NotionError)] -) -async def test_invalid_credentials(hass, mock_aionotion): - """Test that an invalid API/App Key throws an error.""" +@pytest.mark.parametrize("client", [AsyncMock(side_effect=NotionError)]) +async def test_generic_notion_error(client_login, hass): + """Test that a generic aionotion error is handled correctly.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + 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"} -async def test_show_form(hass): - """Test that the form is served with no input.""" - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} +async def test_step_reauth(client_login, hass): + """Test that the reauth step works.""" + MockConfigEntry( + domain=DOMAIN, + 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["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.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - flow = config_flow.NotionFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + with patch("homeassistant.components.notion.async_setup_entry", return_value=True): + 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["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "user@host.com" assert result["data"] == {