diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py index 039cb23a470..c0c1dc4ce19 100644 --- a/homeassistant/components/paperless_ngx/config_flow.py +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pypaperless import Paperless @@ -36,6 +37,7 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" + errors: dict[str, str] = {} if user_input is not None: self._async_abort_entries_match( { @@ -44,31 +46,9 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): } ) - errors: dict[str, str] = {} - if user_input is not None: - client = Paperless( - user_input[CONF_URL], - user_input[CONF_API_KEY], - session=async_get_clientsession(self.hass), - ) + errors = await self._validate_input(user_input) - try: - await client.initialize() - await client.statistics() - except PaperlessConnectionError: - errors[CONF_URL] = "cannot_connect" - except PaperlessInvalidTokenError: - errors[CONF_API_KEY] = "invalid_api_key" - except PaperlessInactiveOrDeletedError: - errors[CONF_API_KEY] = "user_inactive_or_deleted" - except PaperlessForbiddenError: - errors[CONF_API_KEY] = "forbidden" - except InitializationError: - errors[CONF_URL] = "cannot_connect" - except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", err) - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry( title=user_input[CONF_URL], data=user_input ) @@ -76,3 +56,96 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for Paperless-ngx integration.""" + + entry = self._get_reconfigure_entry() + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) + + errors = await self._validate_input(user_input) + + if not errors: + return self.async_update_reload_and_abort(entry, data=user_input) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={ + CONF_URL: user_input[CONF_URL] + if user_input is not None + else entry.data[CONF_URL], + }, + ), + errors=errors, + ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reauth flow for Paperless-ngx integration.""" + + entry = self._get_reauth_entry() + + errors: dict[str, str] = {} + if user_input is not None: + updated_data = {**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]} + + errors = await self._validate_input(updated_data) + + if not errors: + return self.async_update_reload_and_abort( + entry, + data=updated_data, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: + errors: dict[str, str] = {} + + client = Paperless( + user_input[CONF_URL], + user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + + try: + await client.initialize() + await client.statistics() # test permissions on api + except PaperlessConnectionError: + errors[CONF_URL] = "cannot_connect" + except PaperlessInvalidTokenError: + errors[CONF_API_KEY] = "invalid_api_key" + except PaperlessInactiveOrDeletedError: + errors[CONF_API_KEY] = "user_inactive_or_deleted" + except PaperlessForbiddenError: + errors[CONF_API_KEY] = "forbidden" + except InitializationError: + errors[CONF_URL] = "cannot_connect" + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + errors["base"] = "unknown" + + return errors diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py index 542c0fee71f..a8296bbda89 100644 --- a/homeassistant/components/paperless_ngx/coordinator.py +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -17,7 +17,11 @@ from pypaperless.models import Statistic from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -63,12 +67,12 @@ class PaperlessCoordinator(DataUpdateCoordinator[Statistic]): translation_key="cannot_connect", ) from err except PaperlessInvalidTokenError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_api_key", ) from err except PaperlessInactiveOrDeletedError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="user_inactive_or_deleted", ) from err @@ -98,12 +102,12 @@ class PaperlessCoordinator(DataUpdateCoordinator[Statistic]): translation_key="forbidden", ) from err except PaperlessInvalidTokenError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_api_key", ) from err except PaperlessInactiveOrDeletedError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="user_inactive_or_deleted", ) from err diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json index 2ff8aaed4ab..0be3562c76f 100644 --- a/homeassistant/components/paperless_ngx/manifest.json +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pypaperless"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pypaperless==4.1.0"] } diff --git a/homeassistant/components/paperless_ngx/quality_scale.yaml b/homeassistant/components/paperless_ngx/quality_scale.yaml index 31fdc781c2e..827d4425132 100644 --- a/homeassistant/components/paperless_ngx/quality_scale.yaml +++ b/homeassistant/components/paperless_ngx/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json index 224568f4082..dbcd3cf37e1 100644 --- a/homeassistant/components/paperless_ngx/strings.json +++ b/homeassistant/components/paperless_ngx/strings.json @@ -11,6 +11,26 @@ "api_key": "API key to connect to the Paperless-ngx API" }, "title": "Add Paperless-ngx instance" + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + }, + "title": "Re-auth Paperless-ngx instance" + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + }, + "title": "Reconfigure Paperless-ngx instance" } }, "error": { @@ -21,7 +41,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/paperless_ngx/conftest.py b/tests/components/paperless_ngx/conftest.py index 758856f6912..a96a0b115e1 100644 --- a/tests/components/paperless_ngx/conftest.py +++ b/tests/components/paperless_ngx/conftest.py @@ -11,7 +11,7 @@ from homeassistant.components.paperless_ngx.const import DOMAIN from homeassistant.core import HomeAssistant from . import setup_integration -from .const import USER_INPUT +from .const import USER_INPUT_ONE from tests.common import MockConfigEntry, load_fixture @@ -59,10 +59,10 @@ def mock_paperless(mock_statistic_data: MagicMock) -> Generator[AsyncMock]: def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - entry_id="paperless_ngx_test", + entry_id="0KLG00V55WEVTJ0CJHM0GADNGH", title="Paperless-ngx", domain=DOMAIN, - data=USER_INPUT, + data=USER_INPUT_ONE, ) diff --git a/tests/components/paperless_ngx/const.py b/tests/components/paperless_ngx/const.py index 361acaedc6d..addfd54a001 100644 --- a/tests/components/paperless_ngx/const.py +++ b/tests/components/paperless_ngx/const.py @@ -2,7 +2,14 @@ from homeassistant.const import CONF_API_KEY, CONF_URL -USER_INPUT = { +USER_INPUT_ONE = { CONF_URL: "https://192.168.69.16:8000", - CONF_API_KEY: "test_token", + CONF_API_KEY: "12345678", } + +USER_INPUT_TWO = { + CONF_URL: "https://paperless.example.de", + CONF_API_KEY: "87654321", +} + +USER_INPUT_REAUTH = {CONF_API_KEY: "192837465"} diff --git a/tests/components/paperless_ngx/snapshots/test_sensor.ambr b/tests/components/paperless_ngx/snapshots/test_sensor.ambr index ccd48ff8c09..cc197e23ff5 100644 --- a/tests/components/paperless_ngx/snapshots/test_sensor.ambr +++ b/tests/components/paperless_ngx/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'correspondent_count', - 'unique_id': 'paperless_ngx_test_correspondent_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_correspondent_count', 'unit_of_measurement': 'correspondents', }) # --- @@ -82,7 +82,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'document_type_count', - 'unique_id': 'paperless_ngx_test_document_type_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_document_type_count', 'unit_of_measurement': 'document types', }) # --- @@ -133,7 +133,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'documents_inbox', - 'unique_id': 'paperless_ngx_test_documents_inbox', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_documents_inbox', 'unit_of_measurement': 'documents', }) # --- @@ -184,7 +184,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tag_count', - 'unique_id': 'paperless_ngx_test_tag_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_tag_count', 'unit_of_measurement': 'tags', }) # --- @@ -235,7 +235,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'characters_count', - 'unique_id': 'paperless_ngx_test_characters_count', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_characters_count', 'unit_of_measurement': 'characters', }) # --- @@ -286,7 +286,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'documents_total', - 'unique_id': 'paperless_ngx_test_documents_total', + 'unique_id': '0KLG00V55WEVTJ0CJHM0GADNGH_documents_total', 'unit_of_measurement': 'documents', }) # --- diff --git a/tests/components/paperless_ngx/test_config_flow.py b/tests/components/paperless_ngx/test_config_flow.py index 1674296e9a7..b9960818ceb 100644 --- a/tests/components/paperless_ngx/test_config_flow.py +++ b/tests/components/paperless_ngx/test_config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import USER_INPUT +from .const import USER_INPUT_ONE, USER_INPUT_REAUTH, USER_INPUT_TWO from tests.common import MockConfigEntry, patch @@ -46,13 +46,58 @@ async def test_full_config_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + USER_INPUT_ONE, ) config_entry = result["result"] - assert config_entry.title == USER_INPUT[CONF_URL] + assert config_entry.title == USER_INPUT_ONE[CONF_URL] assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == USER_INPUT + assert config_entry.data == USER_INPUT_ONE + + +async def test_full_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth an integration and finishing flow works.""" + + mock_config_entry.add_to_hass(hass) + + reauth_flow = await mock_config_entry.start_reauth_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reauth_confirm" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], USER_INPUT_REAUTH + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == USER_INPUT_REAUTH[CONF_API_KEY] + + +async def test_full_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure an integration and finishing flow works.""" + + mock_config_entry.add_to_hass(hass) + + reconfigure_flow = await mock_config_entry.start_reconfigure_flow(hass) + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reconfigure_flow["flow_id"], + USER_INPUT_TWO, + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "reconfigure_successful" + assert mock_config_entry.data == USER_INPUT_TWO @pytest.mark.parametrize( @@ -78,7 +123,7 @@ async def test_config_flow_error_handling( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=USER_INPUT, + data=USER_INPUT_ONE, ) assert result["type"] is FlowResultType.FORM @@ -89,12 +134,87 @@ async def test_config_flow_error_handling( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=USER_INPUT, + user_input=USER_INPUT_ONE, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT[CONF_URL] - assert result["data"] == USER_INPUT + assert result["title"] == USER_INPUT_ONE[CONF_URL] + assert result["data"] == USER_INPUT_ONE + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PaperlessConnectionError(), {CONF_URL: "cannot_connect"}), + (PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}), + (PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}), + (PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}), + (InitializationError(), {CONF_URL: "cannot_connect"}), + (Exception("BOOM!"), {"base": "unknown"}), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reauth flow with various initialization errors.""" + + mock_config_entry.add_to_hass(hass) + mock_paperless.initialize.side_effect = side_effect + + reauth_flow = await mock_config_entry.start_reauth_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reauth_confirm" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], USER_INPUT_REAUTH + ) + + await hass.async_block_till_done() + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == expected_error + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (PaperlessConnectionError(), {CONF_URL: "cannot_connect"}), + (PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}), + (PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}), + (PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}), + (InitializationError(), {CONF_URL: "cannot_connect"}), + (Exception("BOOM!"), {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_paperless: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow with various initialization errors.""" + + mock_config_entry.add_to_hass(hass) + mock_paperless.initialize.side_effect = side_effect + + reauth_flow = await mock_config_entry.start_reconfigure_flow(hass) + assert reauth_flow["type"] is FlowResultType.FORM + assert reauth_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], + USER_INPUT_TWO, + ) + + await hass.async_block_till_done() + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == expected_error async def test_config_already_exists( @@ -105,8 +225,36 @@ async def test_config_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, - data=USER_INPUT, + data=USER_INPUT_ONE, context={"source": config_entries.SOURCE_USER}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_config_already_exists_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test we only allow a single config if reconfiguring an entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry_two = MockConfigEntry( + entry_id="J87G00V55WEVTJ0CJHM0GADBH5", + title="Paperless-ngx - Two", + domain=DOMAIN, + data=USER_INPUT_TWO, + ) + mock_config_entry_two.add_to_hass(hass) + + reconfigure_flow = await mock_config_entry_two.start_reconfigure_flow(hass) + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "reconfigure" + + result_configure = await hass.config_entries.flow.async_configure( + reconfigure_flow["flow_id"], + USER_INPUT_ONE, + ) + + assert result_configure["type"] is FlowResultType.ABORT + assert result_configure["reason"] == "already_configured" diff --git a/tests/components/paperless_ngx/test_sensor.py b/tests/components/paperless_ngx/test_sensor.py index 2025bba6965..33610d9b6d6 100644 --- a/tests/components/paperless_ngx/test_sensor.py +++ b/tests/components/paperless_ngx/test_sensor.py @@ -12,6 +12,7 @@ from pypaperless.exceptions import ( from pypaperless.models import Statistic import pytest +from homeassistant.components.paperless_ngx.coordinator import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -60,7 +61,7 @@ async def test_statistic_sensor_state( ) ) - freezer.tick(timedelta(seconds=120)) + freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -70,12 +71,12 @@ async def test_statistic_sensor_state( @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( - "error_cls", + ("error_cls", "assert_state"), [ - PaperlessForbiddenError, - PaperlessConnectionError, - PaperlessInactiveOrDeletedError, - PaperlessInvalidTokenError, + (PaperlessForbiddenError, "420"), + (PaperlessConnectionError, "420"), + (PaperlessInactiveOrDeletedError, STATE_UNAVAILABLE), + (PaperlessInvalidTokenError, STATE_UNAVAILABLE), ], ) async def test__statistic_sensor_state_on_error( @@ -84,28 +85,29 @@ async def test__statistic_sensor_state_on_error( freezer: FrozenDateTimeFactory, mock_statistic_data_update, error_cls, + assert_state, ) -> None: """Ensure sensor entities are added automatically.""" # simulate error mock_paperless.statistics.side_effect = error_cls - freezer.tick(timedelta(seconds=120)) + freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.paperless_ngx_total_documents") assert state.state == STATE_UNAVAILABLE - # recover from error + # recover from not auth errors mock_paperless.statistics = AsyncMock( return_value=Statistic.create_with_data( mock_paperless, data=mock_statistic_data_update, fetched=True ) ) - freezer.tick(timedelta(seconds=120)) + freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.paperless_ngx_total_documents") - assert state.state == "420" + assert state.state == assert_state