mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
Add config flow to One-Time Password (OTP) integration (#118493)
This commit is contained in:
parent
60ba80a28d
commit
1eb8b5a27c
@ -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)
|
||||||
|
74
homeassistant/components/otp/config_flow.py
Normal file
74
homeassistant/components/otp/config_flow.py
Normal file
@ -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,
|
||||||
|
)
|
4
homeassistant/components/otp/const.py
Normal file
4
homeassistant/components/otp/const.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Constants for the One-Time Password (OTP) integration."""
|
||||||
|
|
||||||
|
DOMAIN = "otp"
|
||||||
|
DEFAULT_NAME = "OTP Sensor"
|
@ -2,6 +2,7 @@
|
|||||||
"domain": "otp",
|
"domain": "otp",
|
||||||
"name": "One-Time Password (OTP)",
|
"name": "One-Time Password (OTP)",
|
||||||
"codeowners": [],
|
"codeowners": [],
|
||||||
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/otp",
|
"documentation": "https://www.home-assistant.io/integrations/otp",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyotp"],
|
"loggers": ["pyotp"],
|
||||||
|
@ -8,13 +8,15 @@ import pyotp
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
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.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
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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
|
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
|
TIME_STEP = 30 # Default time step assumed by Google Authenticator
|
||||||
|
|
||||||
@ -34,10 +36,32 @@ async def async_setup_platform(
|
|||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the OTP sensor."""
|
"""Set up the OTP sensor."""
|
||||||
name = config[CONF_NAME]
|
async_create_issue(
|
||||||
token = config[CONF_TOKEN]
|
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
|
# Only TOTP supported at the moment, HOTP might be added later
|
||||||
|
19
homeassistant/components/otp/strings.json
Normal file
19
homeassistant/components/otp/strings.json
Normal file
@ -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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -397,6 +397,7 @@ FLOWS = {
|
|||||||
"oralb",
|
"oralb",
|
||||||
"osoenergy",
|
"osoenergy",
|
||||||
"otbr",
|
"otbr",
|
||||||
|
"otp",
|
||||||
"ourgroceries",
|
"ourgroceries",
|
||||||
"overkiz",
|
"overkiz",
|
||||||
"ovo_energy",
|
"ovo_energy",
|
||||||
|
@ -4409,7 +4409,7 @@
|
|||||||
"otp": {
|
"otp": {
|
||||||
"name": "One-Time Password (OTP)",
|
"name": "One-Time Password (OTP)",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"config_flow": false,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
"ourgroceries": {
|
"ourgroceries": {
|
||||||
|
1
tests/components/otp/__init__.py
Normal file
1
tests/components/otp/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Test the One-Time Password (OTP)."""
|
62
tests/components/otp/conftest.py
Normal file
62
tests/components/otp/conftest.py
Normal file
@ -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",
|
||||||
|
}
|
||||||
|
}
|
15
tests/components/otp/snapshots/test_sensor.ambr
Normal file
15
tests/components/otp/snapshots/test_sensor.ambr
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_setup
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'OTP Sensor',
|
||||||
|
'icon': 'mdi:update',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.otp_sensor',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': '123456',
|
||||||
|
})
|
||||||
|
# ---
|
100
tests/components/otp/test_config_flow.py
Normal file
100
tests/components/otp/test_config_flow.py
Normal file
@ -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
|
23
tests/components/otp/test_init.py
Normal file
23
tests/components/otp/test_init.py
Normal file
@ -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
|
41
tests/components/otp/test_sensor.py
Normal file
41
tests/components/otp/test_sensor.py
Normal file
@ -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}"
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user