From 1eb8b5a27c9f1c23c4ce8496c58c2b80394f2512 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Thu, 20 Jun 2024 10:03:29 +0200 Subject: [PATCH] Add config flow to One-Time Password (OTP) integration (#118493) --- homeassistant/components/otp/__init__.py | 23 +++- homeassistant/components/otp/config_flow.py | 74 +++++++++++++ homeassistant/components/otp/const.py | 4 + homeassistant/components/otp/manifest.json | 1 + homeassistant/components/otp/sensor.py | 34 +++++- homeassistant/components/otp/strings.json | 19 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/otp/__init__.py | 1 + tests/components/otp/conftest.py | 62 +++++++++++ .../components/otp/snapshots/test_sensor.ambr | 15 +++ tests/components/otp/test_config_flow.py | 100 ++++++++++++++++++ tests/components/otp/test_init.py | 23 ++++ tests/components/otp/test_sensor.py | 41 +++++++ 14 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/otp/config_flow.py create mode 100644 homeassistant/components/otp/const.py create mode 100644 homeassistant/components/otp/strings.json create mode 100644 tests/components/otp/__init__.py create mode 100644 tests/components/otp/conftest.py create mode 100644 tests/components/otp/snapshots/test_sensor.ambr create mode 100644 tests/components/otp/test_config_flow.py create mode 100644 tests/components/otp/test_init.py create mode 100644 tests/components/otp/test_sensor.py diff --git a/homeassistant/components/otp/__init__.py b/homeassistant/components/otp/__init__.py index bf80d41a92d..5b18301874a 100644 --- a/homeassistant/components/otp/__init__.py +++ b/homeassistant/components/otp/__init__.py @@ -1 +1,22 @@ -"""The otp component.""" +"""The One-Time Password (OTP) integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up One-Time Password (OTP) from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/otp/config_flow.py b/homeassistant/components/otp/config_flow.py new file mode 100644 index 00000000000..7777b9b733a --- /dev/null +++ b/homeassistant/components/otp/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for One-Time Password (OTP) integration.""" + +from __future__ import annotations + +import binascii +import logging +from typing import Any + +import pyotp +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME, CONF_TOKEN + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + } +) + + +class TOTPConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for One-Time Password (OTP).""" + + VERSION = 1 + user_input: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + await self.hass.async_add_executor_job( + pyotp.TOTP(user_input[CONF_TOKEN]).now + ) + except binascii.Error: + errors["base"] = "invalid_code" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_TOKEN]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import config from yaml.""" + + await self.async_set_unique_id(import_info[CONF_TOKEN]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_info.get(CONF_NAME, DEFAULT_NAME), + data=import_info, + ) diff --git a/homeassistant/components/otp/const.py b/homeassistant/components/otp/const.py new file mode 100644 index 00000000000..180e0a4c5a2 --- /dev/null +++ b/homeassistant/components/otp/const.py @@ -0,0 +1,4 @@ +"""Constants for the One-Time Password (OTP) integration.""" + +DOMAIN = "otp" +DEFAULT_NAME = "OTP Sensor" diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json index 758824f8772..f62f89cff40 100644 --- a/homeassistant/components/otp/manifest.json +++ b/homeassistant/components/otp/manifest.json @@ -2,6 +2,7 @@ "domain": "otp", "name": "One-Time Password (OTP)", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/otp", "iot_class": "local_polling", "loggers": ["pyotp"], diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 3a62677dfc2..e612b03f66c 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -8,13 +8,15 @@ import pyotp import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -DEFAULT_NAME = "OTP Sensor" +from .const import DEFAULT_NAME, DOMAIN TIME_STEP = 30 # Default time step assumed by Google Authenticator @@ -34,10 +36,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the OTP sensor.""" - name = config[CONF_NAME] - token = config[CONF_TOKEN] + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2025.1.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "One-Time Password (OTP)", + }, + ) + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) - async_add_entities([TOTPSensor(name, token)], True) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the OTP sensor.""" + + async_add_entities( + [TOTPSensor(entry.data[CONF_NAME], entry.data[CONF_TOKEN])], True + ) # Only TOTP supported at the moment, HOTP might be added later diff --git a/homeassistant/components/otp/strings.json b/homeassistant/components/otp/strings.json new file mode 100644 index 00000000000..fc6031d0433 --- /dev/null +++ b/homeassistant/components/otp/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "token": "Authenticator token (OTP)" + } + } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_token": "Invalid token" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 745bad093d2..5d0718092e5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -397,6 +397,7 @@ FLOWS = { "oralb", "osoenergy", "otbr", + "otp", "ourgroceries", "overkiz", "ovo_energy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 43b1c1b45f7..4133de4d4a3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4409,7 +4409,7 @@ "otp": { "name": "One-Time Password (OTP)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "ourgroceries": { diff --git a/tests/components/otp/__init__.py b/tests/components/otp/__init__.py new file mode 100644 index 00000000000..91a7412323b --- /dev/null +++ b/tests/components/otp/__init__.py @@ -0,0 +1 @@ +"""Test the One-Time Password (OTP).""" diff --git a/tests/components/otp/conftest.py b/tests/components/otp/conftest.py new file mode 100644 index 00000000000..a4e139637c4 --- /dev/null +++ b/tests/components/otp/conftest.py @@ -0,0 +1,62 @@ +"""Common fixtures for the One-Time Password (OTP) tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.otp.const import DOMAIN +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_TOKEN +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.otp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_pyotp() -> Generator[MagicMock, None, None]: + """Mock a pyotp.""" + with ( + patch( + "homeassistant.components.otp.config_flow.pyotp", + ) as mock_client, + patch("homeassistant.components.otp.sensor.pyotp", new=mock_client), + ): + mock_totp = MagicMock() + mock_totp.now.return_value = 123456 + mock_client.TOTP.return_value = mock_totp + yield mock_client + + +@pytest.fixture(name="otp_config_entry") +def mock_otp_config_entry() -> MockConfigEntry: + """Mock otp configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "OTP Sensor", + CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + }, + unique_id="2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + ) + + +@pytest.fixture(name="otp_yaml_config") +def mock_otp_yaml_config() -> ConfigType: + """Mock otp configuration entry.""" + return { + SENSOR_DOMAIN: { + CONF_PLATFORM: "otp", + CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + CONF_NAME: "OTP Sensor", + } + } diff --git a/tests/components/otp/snapshots/test_sensor.ambr b/tests/components/otp/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fbd8741b8b5 --- /dev/null +++ b/tests/components/otp/snapshots/test_sensor.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OTP Sensor', + 'icon': 'mdi:update', + }), + 'context': , + 'entity_id': 'sensor.otp_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123456', + }) +# --- diff --git a/tests/components/otp/test_config_flow.py b/tests/components/otp/test_config_flow.py new file mode 100644 index 00000000000..b0bd3e915bd --- /dev/null +++ b/tests/components/otp/test_config_flow.py @@ -0,0 +1,100 @@ +"""Test the One-Time Password (OTP) config flow.""" + +import binascii +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.otp.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +TEST_DATA = { + CONF_NAME: "OTP Sensor", + CONF_TOKEN: "TOKEN_A", +} + + +@pytest.mark.usefixtures("mock_pyotp") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (binascii.Error, "invalid_code"), + (IndexError, "unknown"), + ], +) +async def test_errors_and_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyotp: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pyotp.TOTP().now.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_pyotp.TOTP().now.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyotp", "mock_setup_entry") +async def test_flow_import(hass: HomeAssistant) -> None: + """Test that we can import a YAML config.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA diff --git a/tests/components/otp/test_init.py b/tests/components/otp/test_init.py new file mode 100644 index 00000000000..0ce8f44523e --- /dev/null +++ b/tests/components/otp/test_init.py @@ -0,0 +1,23 @@ +"""Test the One-Time Password (OTP) init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, otp_config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + otp_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert otp_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert otp_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/otp/test_sensor.py b/tests/components/otp/test_sensor.py new file mode 100644 index 00000000000..b9901c4a914 --- /dev/null +++ b/tests/components/otp/test_sensor.py @@ -0,0 +1,41 @@ +"""Tests for the One-Time Password (OTP) Sensors.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.otp.const import DOMAIN +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pyotp") +async def test_setup( + hass: HomeAssistant, + otp_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of ista EcoTrend sensor platform.""" + + otp_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.otp_sensor") == snapshot + + +async def test_deprecated_yaml_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, otp_yaml_config: ConfigType +) -> None: + """Test an issue is created when attempting setup from yaml config.""" + + assert await async_setup_component(hass, SENSOR_DOMAIN, otp_yaml_config) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" + )