Add Geocaching integration (#50284)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Reinder Reinders <reinder.reinders@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Rudolf Offereins
2022-05-12 12:12:47 +02:00
committed by GitHub
parent 135326a4a6
commit 577b8cd976
20 changed files with 820 additions and 0 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -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

View File

@ -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())

View File

@ -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)

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -121,6 +121,7 @@ FLOWS = {
"garages_amsterdam",
"gdacs",
"generic",
"geocaching",
"geofency",
"geonetnz_quakes",
"geonetnz_volcano",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
"""Tests for the Geocaching integration."""
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
REDIRECT_URI = "https://example.com/auth/external/callback"

View File

@ -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

View File

@ -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