Add option to login with username/email and password in Habitica integration (#117622)

* add login/password authentication

* add advanced config flow

* remove unused exception classes, fix errors

* update username in init

* update tests

* update strings

* combine steps with menu

* remove username from entry

* update tests

* Revert "update tests"

This reverts commit 6ac8ad6a26547b623e217db817ec4d0cf8c91f1d.

* Revert "remove username from entry"

This reverts commit d9323fb72df3f9d41be0a53bb0cbe16be718d005.

* small changes

* remove pylint broad-excep

* run habitipy init in executor

* Add text selectors

* changes
This commit is contained in:
Mr. Bubbles 2024-08-30 17:08:06 +02:00 committed by GitHub
parent 20f9b9e412
commit 50577883dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 318 additions and 97 deletions

View file

@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_SENSORS,
CONF_URL,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
@ -125,6 +126,7 @@ async def async_setup_entry(
name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH]
entries = hass.config_entries.async_entries(DOMAIN)
api = None
for entry in entries:
if entry.data[CONF_NAME] == name:
@ -147,18 +149,16 @@ async def async_setup_entry(
EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data}
)
websession = async_get_clientsession(hass)
url = config_entry.data[CONF_URL]
username = config_entry.data[CONF_API_USER]
password = config_entry.data[CONF_API_KEY]
websession = async_get_clientsession(
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
api = await hass.async_add_executor_job(
HAHabitipyAsync,
{
"url": url,
"login": username,
"password": password,
"url": config_entry.data[CONF_URL],
"login": config_entry.data[CONF_API_USER],
"password": config_entry.data[CONF_API_KEY],
},
)
try:

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from http import HTTPStatus
import logging
from typing import Any
@ -10,48 +11,53 @@ from habitipy.aio import HabitipyAsync
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import (
CONF_API_KEY,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import CONF_API_USER, DEFAULT_URL, DOMAIN
DATA_SCHEMA = vol.Schema(
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_USER): str,
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_NAME): str,
vol.Optional(CONF_URL, default=DEFAULT_URL): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
)
STEP_LOGIN_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]:
"""Validate the user input allows us to connect."""
websession = async_get_clientsession(hass)
api = await hass.async_add_executor_job(
HabitipyAsync,
{
"login": data[CONF_API_USER],
"password": data[CONF_API_KEY],
"url": data[CONF_URL] or DEFAULT_URL,
},
)
try:
await api.user.get(session=websession)
return {
"title": f"{data.get('name', 'Default username')}",
CONF_API_USER: data[CONF_API_USER],
}
except ClientResponseError as ex:
raise InvalidAuth from ex
class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for habitica."""
@ -62,24 +68,115 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
return self.async_show_menu(
step_id="user",
menu_options=["login", "advanced"],
)
async def async_step_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Config flow with username/password.
Simplified configuration setup that retrieves API credentials
from Habitica.com by authenticating with login and password.
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except InvalidAuth:
errors = {"base": "invalid_credentials"}
session = async_get_clientsession(self.hass)
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": "",
"password": "",
"url": DEFAULT_URL,
},
)
login_response = await api.user.auth.local.login.post(
session=session,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors = {"base": "unknown"}
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info[CONF_API_USER])
await self.async_set_unique_id(login_response["id"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_create_entry(
title=login_response["username"],
data={
CONF_API_USER: login_response["id"],
CONF_API_KEY: login_response["apiToken"],
CONF_USERNAME: login_response["username"],
CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True,
},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
step_id="login",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_LOGIN_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
)
async def async_step_advanced(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Advanced configuration with User Id and API Token.
Advanced configuration allows connecting to Habitica instances
hosted on different domains or to self-hosted instances.
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
session = async_get_clientsession(
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
)
api = await self.hass.async_add_executor_job(
HabitipyAsync,
{
"login": user_input[CONF_API_USER],
"password": user_input[CONF_API_KEY],
"url": user_input.get(CONF_URL, DEFAULT_URL),
},
)
api_response = await api.user.get(
session=session,
userFields="auth",
)
except ClientResponseError as ex:
if ex.status == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_API_USER])
self._abort_if_unique_id_configured()
user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"]
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="advanced",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_ADVANCED_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={},
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
@ -98,8 +195,4 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
"integration_title": "Habitica",
},
)
return await self.async_step_user(import_data)
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
return await self.async_step_advanced(import_data)

View file

@ -4,18 +4,32 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"menu_options": {
"login": "Login to Habitica",
"advanced": "Login to other instances"
},
"description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks."
},
"login": {
"data": {
"username": "Email or username (case-sensitive)",
"password": "[%key:common::config_flow::data::password%]"
}
},
"advanced": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"name": "Override for Habiticas username. Will be used for actions",
"api_user": "Habiticas API user ID",
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_user": "User ID",
"api_key": "API Token",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api"
"description": "You can retrieve your `User ID` and `API Token` from **Settings -> Site Data** on Habitica or the instance you want to connect to"
}
}
},

View file

@ -3,26 +3,152 @@
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientResponseError
import pytest
from homeassistant import config_entries
from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN
from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN
from homeassistant.const import (
CONF_API_KEY,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
MOCK_DATA_LOGIN_STEP = {
CONF_USERNAME: "test-email@example.com",
CONF_PASSWORD: "test-password",
}
MOCK_DATA_ADVANCED_STEP = {
CONF_API_USER: "test-api-user",
CONF_API_KEY: "test-api-key",
CONF_URL: DEFAULT_URL,
CONF_VERIFY_SSL: True,
}
async def test_form(hass: HomeAssistant) -> None:
async def test_form_login(hass: HomeAssistant) -> None:
"""Test we get the login form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert "login" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "login"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["step_id"] == "login"
mock_obj = MagicMock()
mock_obj.user.auth.local.login.post = AsyncMock()
mock_obj.user.auth.local.login.post.return_value = {
"id": "test-api-user",
"apiToken": "test-api-key",
"username": "test-username",
}
with (
patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
),
patch(
"homeassistant.components.habitica.async_setup", return_value=True
) as mock_setup,
patch(
"homeassistant.components.habitica.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["data"] == {
**MOCK_DATA_ADVANCED_STEP,
CONF_USERNAME: "test-username",
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"),
(IndexError(), "unknown"),
],
)
async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -> None:
"""Test we handle invalid credentials error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "login"}
)
mock_obj = MagicMock()
mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error)
with patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_DATA_LOGIN_STEP,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": text_error}
async def test_form_advanced(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert "advanced" in result["menu_options"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "advanced"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["step_id"] == "advanced"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "advanced"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock()
mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}}
with (
patch(
@ -39,29 +165,46 @@ async def test_form(hass: HomeAssistant) -> None:
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"api_user": "test-api-user", "api_key": "test-api-key"},
user_input=MOCK_DATA_ADVANCED_STEP,
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Default username"
assert result2["title"] == "test-username"
assert result2["data"] == {
"url": DEFAULT_URL,
"api_user": "test-api-user",
"api_key": "test-api-key",
**MOCK_DATA_ADVANCED_STEP,
CONF_USERNAME: "test-username",
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_credentials(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(ClientResponseError(MagicMock(), (), status=400), "cannot_connect"),
(ClientResponseError(MagicMock(), (), status=401), "invalid_auth"),
(IndexError(), "unknown"),
],
)
async def test_form_advanced_errors(
hass: HomeAssistant, raise_error, text_error
) -> None:
"""Test we handle invalid credentials error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "advanced"}
)
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock(side_effect=ClientResponseError(MagicMock(), ()))
mock_obj.user.get = AsyncMock(side_effect=raise_error)
with patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
@ -69,41 +212,11 @@ async def test_form_invalid_credentials(hass: HomeAssistant) -> None:
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": DEFAULT_URL,
"api_user": "test-api-user",
"api_key": "test-api-key",
},
user_input=MOCK_DATA_ADVANCED_STEP,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_credentials"}
async def test_form_unexpected_exception(hass: HomeAssistant) -> None:
"""Test we handle unexpected exception error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock(side_effect=Exception)
with patch(
"homeassistant.components.habitica.config_flow.HabitipyAsync",
return_value=mock_obj,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": DEFAULT_URL,
"api_user": "test-api-user",
"api_key": "test-api-key",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
assert result2["errors"] == {"base": text_error}
async def test_manual_flow_config_exist(hass: HomeAssistant) -> None:
@ -119,7 +232,7 @@ async def test_manual_flow_config_exist(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "advanced"
mock_obj = MagicMock()
mock_obj.user.get = AsyncMock(return_value={"api_user": "test-api-user"})

View file

@ -52,6 +52,7 @@ def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker:
"https://habitica.com/api/v3/user",
json={
"data": {
"auth": {"local": {"username": TEST_USER_NAME}},
"api_user": "test-api-user",
"profile": {"name": TEST_USER_NAME},
"stats": {