From 577b8cd97638124f93f8f4dc18809214d0240bdd Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Thu, 12 May 2022 12:12:47 +0200 Subject: [PATCH] Add Geocaching integration (#50284) Co-authored-by: Paulus Schoutsen Co-authored-by: Reinder Reinders Co-authored-by: Franck Nijhof --- .coveragerc | 5 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/geocaching/__init__.py | 81 ++++++ .../components/geocaching/config_flow.py | 56 ++++ homeassistant/components/geocaching/const.py | 27 ++ .../components/geocaching/coordinator.py | 47 ++++ .../components/geocaching/manifest.json | 10 + homeassistant/components/geocaching/models.py | 9 + homeassistant/components/geocaching/oauth.py | 77 ++++++ homeassistant/components/geocaching/sensor.py | 126 +++++++++ .../components/geocaching/strings.json | 25 ++ .../geocaching/translations/en.json | 25 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/geocaching/__init__.py | 5 + tests/components/geocaching/conftest.py | 50 ++++ .../components/geocaching/test_config_flow.py | 256 ++++++++++++++++++ 20 files changed, 820 insertions(+) create mode 100644 homeassistant/components/geocaching/__init__.py create mode 100644 homeassistant/components/geocaching/config_flow.py create mode 100644 homeassistant/components/geocaching/const.py create mode 100644 homeassistant/components/geocaching/coordinator.py create mode 100644 homeassistant/components/geocaching/manifest.json create mode 100644 homeassistant/components/geocaching/models.py create mode 100644 homeassistant/components/geocaching/oauth.py create mode 100644 homeassistant/components/geocaching/sensor.py create mode 100644 homeassistant/components/geocaching/strings.json create mode 100644 homeassistant/components/geocaching/translations/en.json create mode 100644 tests/components/geocaching/__init__.py create mode 100644 tests/components/geocaching/conftest.py create mode 100644 tests/components/geocaching/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 706122d0a07..78f63d30cbe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -411,6 +411,11 @@ omit = homeassistant/components/garages_amsterdam/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* + homeassistant/components/geocaching/__init__.py + homeassistant/components/geocaching/const.py + homeassistant/components/geocaching/coordinator.py + homeassistant/components/geocaching/oauth.py + homeassistant/components/geocaching/sensor.py homeassistant/components/github/__init__.py homeassistant/components/github/coordinator.py homeassistant/components/github/sensor.py diff --git a/.strict-typing b/.strict-typing index f7264591c83..8c580dfa1aa 100644 --- a/.strict-typing +++ b/.strict-typing @@ -97,6 +97,7 @@ homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fritz.* homeassistant.components.geo_location.* +homeassistant.components.geocaching.* homeassistant.components.gios.* homeassistant.components.goalzero.* homeassistant.components.greeneye_monitor.* diff --git a/CODEOWNERS b/CODEOWNERS index 19450d86bba..8be3146b432 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -374,6 +374,8 @@ build.json @home-assistant/supervisor /tests/components/geo_location/ @home-assistant/core /homeassistant/components/geo_rss_events/ @exxamalte /tests/components/geo_rss_events/ @exxamalte +/homeassistant/components/geocaching/ @Sholofly @reinder83 +/tests/components/geocaching/ @Sholofly @reinder83 /homeassistant/components/geonetnz_quakes/ @exxamalte /tests/components/geonetnz_quakes/ @exxamalte /homeassistant/components/geonetnz_volcano/ @exxamalte diff --git a/homeassistant/components/geocaching/__init__.py b/homeassistant/components/geocaching/__init__.py new file mode 100644 index 00000000000..f04fbbd608f --- /dev/null +++ b/homeassistant/components/geocaching/__init__.py @@ -0,0 +1,81 @@ +"""The Geocaching integration.""" +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.typing import ConfigType + +from .config_flow import GeocachingFlowHandler +from .const import DOMAIN +from .coordinator import GeocachingDataUpdateCoordinator +from .oauth import GeocachingOAuth2Implementation + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Geocaching component.""" + if DOMAIN not in config: + return True + + GeocachingFlowHandler.async_register_implementation( + hass, + GeocachingOAuth2Implementation( + hass, + client_id=config[DOMAIN][CONF_CLIENT_ID], + client_secret=config[DOMAIN][CONF_CLIENT_SECRET], + name="Geocaching", + ), + ) + + # When manual configuration is done, discover the integration. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY} + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Geocaching from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + oauth_session = OAuth2Session(hass, entry, implementation) + coordinator = GeocachingDataUpdateCoordinator( + hass, entry=entry, session=oauth_session + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/geocaching/config_flow.py b/homeassistant/components/geocaching/config_flow.py new file mode 100644 index 00000000000..83c9ed17586 --- /dev/null +++ b/homeassistant/components/geocaching/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Geocaching.""" +from __future__ import annotations + +import logging +from typing import Any + +from geocachingapi.geocachingapi import GeocachingApi + +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .const import DOMAIN, ENVIRONMENT + + +class GeocachingFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Geocaching OAuth2 authentication.""" + + DOMAIN = DOMAIN + VERSION = 1 + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm(user_input=user_input) + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + api = GeocachingApi( + environment=ENVIRONMENT, + token=data["token"]["access_token"], + session=async_get_clientsession(self.hass), + ) + status = await api.update() + if not status.user or not status.user.username: + return self.async_abort(reason="oauth_error") + + if existing_entry := await self.async_set_unique_id( + status.user.username.lower() + ): + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=status.user.username, data=data) diff --git a/homeassistant/components/geocaching/const.py b/homeassistant/components/geocaching/const.py new file mode 100644 index 00000000000..13b42b318c0 --- /dev/null +++ b/homeassistant/components/geocaching/const.py @@ -0,0 +1,27 @@ +"""Constants for the Geocaching integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from geocachingapi.models import GeocachingApiEnvironment + +from .models import GeocachingOAuthApiUrls + +DOMAIN: Final = "geocaching" +LOGGER = logging.getLogger(__package__) +UPDATE_INTERVAL = timedelta(hours=1) + +ENVIRONMENT_URLS = { + GeocachingApiEnvironment.Staging: GeocachingOAuthApiUrls( + authorize_url="https://staging.geocaching.com/oauth/authorize.aspx", + token_url="https://oauth-staging.geocaching.com/token", + ), + GeocachingApiEnvironment.Production: GeocachingOAuthApiUrls( + authorize_url="https://www.geocaching.com/oauth/authorize.aspx", + token_url="https://oauth.geocaching.com/token", + ), +} + +ENVIRONMENT = GeocachingApiEnvironment.Production diff --git a/homeassistant/components/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py new file mode 100644 index 00000000000..f02cccf544b --- /dev/null +++ b/homeassistant/components/geocaching/coordinator.py @@ -0,0 +1,47 @@ +"""Provides the Geocaching DataUpdateCoordinator.""" +from __future__ import annotations + +from geocachingapi.exceptions import GeocachingApiError +from geocachingapi.geocachingapi import GeocachingApi +from geocachingapi.models import GeocachingStatus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, ENVIRONMENT, LOGGER, UPDATE_INTERVAL + + +class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): + """Class to manage fetching Geocaching data from single endpoint.""" + + def __init__( + self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + ) -> None: + """Initialize global Geocaching data updater.""" + self.session = session + self.entry = entry + + async def async_token_refresh() -> str: + await session.async_ensure_token_valid() + token = session.token["access_token"] + LOGGER.debug(str(token)) + return str(token) + + client_session = async_get_clientsession(hass) + self.geocaching = GeocachingApi( + environment=ENVIRONMENT, + token=session.token["access_token"], + session=client_session, + token_refresh_method=async_token_refresh, + ) + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> GeocachingStatus: + try: + return await self.geocaching.update() + except GeocachingApiError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json new file mode 100644 index 00000000000..683a23d474a --- /dev/null +++ b/homeassistant/components/geocaching/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "geocaching", + "name": "Geocaching", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geocaching", + "requirements": ["geocachingapi==0.2.1"], + "dependencies": ["auth"], + "codeowners": ["@Sholofly", "@reinder83"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/geocaching/models.py b/homeassistant/components/geocaching/models.py new file mode 100644 index 00000000000..60ee4e05978 --- /dev/null +++ b/homeassistant/components/geocaching/models.py @@ -0,0 +1,9 @@ +"""Models for the Geocaching integration.""" +from typing import TypedDict + + +class GeocachingOAuthApiUrls(TypedDict): + """oAuth2 urls for a single environment.""" + + authorize_url: str + token_url: str diff --git a/homeassistant/components/geocaching/oauth.py b/homeassistant/components/geocaching/oauth.py new file mode 100644 index 00000000000..29371eb793c --- /dev/null +++ b/homeassistant/components/geocaching/oauth.py @@ -0,0 +1,77 @@ +"""oAuth2 functions and classes for Geocaching API integration.""" +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, ENVIRONMENT, ENVIRONMENT_URLS + + +class GeocachingOAuth2Implementation( + config_entry_oauth2_flow.LocalOAuth2Implementation +): + """Local OAuth2 implementation for Geocaching.""" + + def __init__( + self, hass: HomeAssistant, client_id: str, client_secret: str, name: str + ) -> None: + """Local Geocaching Oauth Implementation.""" + self._name = name + super().__init__( + hass=hass, + client_id=client_id, + client_secret=client_secret, + domain=DOMAIN, + authorize_url=ENVIRONMENT_URLS[ENVIRONMENT]["authorize_url"], + token_url=ENVIRONMENT_URLS[ENVIRONMENT]["token_url"], + ) + + @property + def name(self) -> str: + """Name of the implementation.""" + return f"{self._name}" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": "*", "response_type": "code"} + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Initialize local Geocaching API auth implementation.""" + redirect_uri = external_data["state"]["redirect_uri"] + data = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": redirect_uri, + } + token = await self._token_request(data) + # Store the redirect_uri (Needed for refreshing token, but not according to oAuth2 spec!) + token["redirect_uri"] = redirect_uri + return token + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "refresh_token", + "refresh_token": token["refresh_token"], + # Add previously stored redirect_uri (Mandatory, but not according to oAuth2 spec!) + "redirect_uri": token["redirect_uri"], + } + + new_token = await self._token_request(data) + return {**token, **new_token} + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + data["client_id"] = self.client_id + if self.client_secret is not None: + data["client_secret"] = self.client_secret + session = async_get_clientsession(self.hass) + resp = await session.post(ENVIRONMENT_URLS[ENVIRONMENT]["token_url"], data=data) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py new file mode 100644 index 00000000000..82353a81484 --- /dev/null +++ b/homeassistant/components/geocaching/sensor.py @@ -0,0 +1,126 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from geocachingapi.models import GeocachingStatus + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GeocachingDataUpdateCoordinator + + +@dataclass +class GeocachingRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[GeocachingStatus], str | int | None] + + +@dataclass +class GeocachingSensorEntityDescription( + SensorEntityDescription, GeocachingRequiredKeysMixin +): + """Define Sensor entity description class.""" + + +SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( + GeocachingSensorEntityDescription( + key="username", + name="username", + icon="mdi:account", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status: status.user.username, + ), + GeocachingSensorEntityDescription( + key="find_count", + name="Total finds", + icon="mdi:notebook-edit-outline", + native_unit_of_measurement="caches", + value_fn=lambda status: status.user.find_count, + ), + GeocachingSensorEntityDescription( + key="hide_count", + name="Total hides", + icon="mdi:eye-off-outline", + native_unit_of_measurement="caches", + entity_registry_visible_default=False, + value_fn=lambda status: status.user.hide_count, + ), + GeocachingSensorEntityDescription( + key="favorite_points", + name="Favorite points", + icon="mdi:heart-outline", + native_unit_of_measurement="points", + entity_registry_visible_default=False, + value_fn=lambda status: status.user.favorite_points, + ), + GeocachingSensorEntityDescription( + key="souvenir_count", + name="Total souvenirs", + icon="mdi:license", + native_unit_of_measurement="souvenirs", + value_fn=lambda status: status.user.souvenir_count, + ), + GeocachingSensorEntityDescription( + key="awarded_favorite_points", + name="Awarded favorite points", + icon="mdi:heart", + native_unit_of_measurement="points", + entity_registry_visible_default=False, + value_fn=lambda status: status.user.awarded_favorite_points, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a Geocaching sensor entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + GeocachingSensor(coordinator, description) for description in SENSORS + ) + + +class GeocachingSensor( + CoordinatorEntity[GeocachingDataUpdateCoordinator], SensorEntity +): + """Representation of a Sensor.""" + + entity_description: GeocachingSensorEntityDescription + + def __init__( + self, + coordinator: GeocachingDataUpdateCoordinator, + description: GeocachingSensorEntityDescription, + ) -> None: + """Initialize the Geocaching sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = ( + f"Geocaching {coordinator.data.user.username} {description.name}" + ) + self._attr_unique_id = ( + f"{coordinator.data.user.reference_code}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + name=f"Geocaching {coordinator.data.user.username}", + identifiers={(DOMAIN, cast(str, coordinator.data.user.reference_code))}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Groundspeak, Inc.", + ) + + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json new file mode 100644 index 00000000000..7c8547805d1 --- /dev/null +++ b/homeassistant/components/geocaching/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Geocaching integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/geocaching/translations/en.json b/homeassistant/components/geocaching/translations/en.json new file mode 100644 index 00000000000..7f76bce4f3d --- /dev/null +++ b/homeassistant/components/geocaching/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "authorize_url_timeout": "Timeout generating authorize URL.", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth_error": "Received invalid token data.", + "reauth_successful": "Re-authentication was successful" + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "description": "The Geocaching integration needs to re-authenticate your account", + "title": "Reauthenticate Integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4bcfa0dbbef..70451b22001 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ FLOWS = { "garages_amsterdam", "gdacs", "generic", + "geocaching", "geofency", "geonetnz_quakes", "geonetnz_volcano", diff --git a/mypy.ini b/mypy.ini index ab4ba77c98b..532c4526372 100644 --- a/mypy.ini +++ b/mypy.ini @@ -830,6 +830,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.geocaching.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.gios.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c2fc83e847b..3ecdceca859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -694,6 +694,9 @@ gcal-sync==0.7.1 # homeassistant.components.geniushub geniushub-client==0.6.30 +# homeassistant.components.geocaching +geocachingapi==0.2.1 + # homeassistant.components.usgs_earthquakes_feed geojson_client==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fe34d6a33e..c23b9d58593 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -488,6 +488,9 @@ garages-amsterdam==3.0.0 # homeassistant.components.google gcal-sync==0.7.1 +# homeassistant.components.geocaching +geocachingapi==0.2.1 + # homeassistant.components.usgs_earthquakes_feed geojson_client==0.6 diff --git a/tests/components/geocaching/__init__.py b/tests/components/geocaching/__init__.py new file mode 100644 index 00000000000..8bc72aa3799 --- /dev/null +++ b/tests/components/geocaching/__init__.py @@ -0,0 +1,5 @@ +"""Tests for the Geocaching integration.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +REDIRECT_URI = "https://example.com/auth/external/callback" diff --git a/tests/components/geocaching/conftest.py b/tests/components/geocaching/conftest.py new file mode 100644 index 00000000000..f59f428118e --- /dev/null +++ b/tests/components/geocaching/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures for the Geocaching integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from geocachingapi import GeocachingStatus +import pytest + +from homeassistant.components.geocaching.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="1234AB 1", + domain=DOMAIN, + data={ + "id": "mock_user", + "auth_implementation": DOMAIN, + }, + unique_id="mock_user", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.geocaching.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_geocaching_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Geocaching API client.""" + + mock_status = GeocachingStatus() + mock_status.user.username = "mock_user" + + with patch( + "homeassistant.components.geocaching.config_flow.GeocachingApi", autospec=True + ) as geocaching_mock: + geocachingapi = geocaching_mock.return_value + geocachingapi.update.return_value = mock_status + yield geocachingapi diff --git a/tests/components/geocaching/test_config_flow.py b/tests/components/geocaching/test_config_flow.py new file mode 100644 index 00000000000..0f5d182b2db --- /dev/null +++ b/tests/components/geocaching/test_config_flow.py @@ -0,0 +1,256 @@ +"""Test the Geocaching config flow.""" +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import MagicMock + +from aiohttp.test_utils import TestClient + +from homeassistant.components.geocaching.const import ( + DOMAIN, + ENVIRONMENT, + ENVIRONMENT_URLS, +) +from homeassistant.config_entries import ( + DEFAULT_DISCOVERY_UNIQUE_ID, + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_EXTERNAL_STEP +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from . import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +CURRENT_ENVIRONMENT_URLS = ENVIRONMENT_URLS[ENVIRONMENT] + + +async def setup_geocaching_component(hass: HomeAssistant) -> bool: + """Set up the Geocaching component.""" + return await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + }, + ) + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_geocaching_config_flow: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Check full flow.""" + assert await setup_geocaching_component(hass) + + # Ensure integration is discovered when manual implementation is configured + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "context" in flows[0] + assert flows[0]["context"]["source"] == SOURCE_INTEGRATION_DISCOVERY + assert flows[0]["context"]["unique_id"] == DEFAULT_DISCOVERY_UNIQUE_ID + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + assert result.get("type") == RESULT_TYPE_EXTERNAL_STEP + assert result.get("step_id") == "auth" + assert result.get("url") == ( + f"{CURRENT_ENVIRONMENT_URLS['authorize_url']}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}&scope=*" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + CURRENT_ENVIRONMENT_URLS["token_url"], + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_existing_entry( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_geocaching_config_flow: MagicMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Check existing entry.""" + assert await setup_geocaching_component(hass) + mock_config_entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + CURRENT_ENVIRONMENT_URLS["token_url"], + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_oauth_error( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_geocaching_config_flow: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Check if aborted when oauth error occurs.""" + assert await setup_geocaching_component(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + assert result.get("type") == RESULT_TYPE_EXTERNAL_STEP + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + + # No user information is returned from API + mock_geocaching_config_flow.update.return_value.user = None + + aioclient_mock.post( + CURRENT_ENVIRONMENT_URLS["token_url"], + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "oauth_error" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_geocaching_config_flow: MagicMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Geocaching reauthentication.""" + mock_config_entry.add_to_hass(hass) + assert await setup_geocaching_component(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH} + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + assert "flow_id" in result + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + CURRENT_ENVIRONMENT_URLS["token_url"], + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1