diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 87d76b9f4c8..f158db884dc 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,23 +1,20 @@ """Support for Google - Calendar Event Devices.""" from __future__ import annotations +import asyncio from collections.abc import Mapping -from datetime import datetime, timedelta, timezone -from enum import Enum +from datetime import datetime, timedelta import logging from typing import Any -from oauth2client.client import ( - FlowExchangeError, - OAuth2DeviceCodeError, - OAuth2WebServerFlow, -) +from httplib2.error import ServerNotFoundError from oauth2client.file import Storage import voluptuous as vol from voluptuous.error import Error as VoluptuousError import yaml -from homeassistant.components import persistent_notification +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -25,20 +22,28 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_NAME, CONF_OFFSET, - Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import config_entry_oauth2_flow import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType -from .api import GoogleCalendarService +from . import config_flow +from .api import DeviceAuth, GoogleCalendarService +from .const import ( + CONF_CALENDAR_ACCESS, + DATA_CONFIG, + DATA_SERVICE, + DISCOVER_CALENDAR, + DOMAIN, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = "google" ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_TRACK_NEW = "track_new_calendar" @@ -48,7 +53,6 @@ CONF_TRACK = "track" CONF_SEARCH = "search" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_MAX_RESULTS = "max_results" -CONF_CALENDAR_ACCESS = "calendar_access" DEFAULT_CONF_OFFSET = "!!" @@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars" SERVICE_FOUND_CALENDARS = "found_calendar" SERVICE_ADD_EVENT = "add_event" -DATA_SERVICE = "service" - YAML_DEVICES = f"{DOMAIN}_calendars.yaml" TOKEN_FILE = f".{DOMAIN}.token" - -class FeatureAccess(Enum): - """Class to represent different access scopes.""" - - read_only = "https://www.googleapis.com/auth/calendar.readonly" - read_write = "https://www.googleapis.com/auth/calendar" - - def __init__(self, scope: str) -> None: - """Init instance.""" - self._scope = scope - - @property - def scope(self) -> str: - """Google calendar scope for the feature.""" - return self._scope +PLATFORMS = ["calendar"] CONFIG_SCHEMA = vol.Schema( @@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema( ) -def do_authentication( - hass: HomeAssistant, - hass_config: ConfigType, - config: ConfigType, - storage: Storage, -) -> bool: - """Notify user of actions and authenticate. - - Notify user of user_code and verification_url then poll - until we have an access token. - """ - oauth = OAuth2WebServerFlow( - client_id=config[CONF_CLIENT_ID], - client_secret=config[CONF_CLIENT_SECRET], - scope=config[CONF_CALENDAR_ACCESS].scope, - redirect_uri="Home-Assistant.io", - ) - try: - dev_flow = oauth.step1_get_device_and_user_codes() - except OAuth2DeviceCodeError as err: - persistent_notification.create( - hass, - f"Error: {err}
You will need to restart hass after fixing." "", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False - - persistent_notification.create( +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Google component.""" + conf = config.get(DOMAIN, {}) + hass.data[DOMAIN] = {DATA_CONFIG: conf} + config_flow.OAuth2FlowHandler.async_register_implementation( hass, - ( - f"In order to authorize Home-Assistant to view your calendars " - f'you must visit: {dev_flow.verification_url} and enter ' - f"code: {dev_flow.user_code}" - ), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - listener: CALLBACK_TYPE | None = None - - def step2_exchange(now: datetime) -> None: - """Keep trying to validate the user_code until it expires.""" - _LOGGER.debug("Attempting to validate user code") - - # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime - # object without tzinfo. For the comparison below to work, it needs one. - user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc) - - if now >= user_code_expiry: - persistent_notification.create( - hass, - "Authentication code expired, please restart " - "Home-Assistant and try again", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - assert listener - listener() - return - - try: - credentials = oauth.step2_exchange(device_flow_info=dev_flow) - except FlowExchangeError: - # not ready yet, call again - return - - storage.put(credentials) - do_setup(hass, hass_config, config) - assert listener - listener() - persistent_notification.create( + DeviceAuth( hass, - ( - f"We are all setup now. Check {YAML_DEVICES} for calendars that have " - f"been found" - ), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - listener = track_utc_time_change( - hass, step2_exchange, second=range(1, 60, dev_flow.interval) + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), ) - return True - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Google platform.""" - - if not (conf := config.get(DOMAIN, {})): - # component is set up by tts platform - return True - + # Import credentials from the old token file into the new way as + # a ConfigEntry managed by home assistant. storage = Storage(hass.config.path(TOKEN_FILE)) - hass.data[DOMAIN] = { - DATA_SERVICE: GoogleCalendarService(hass, storage), - } - creds = storage.get() - if ( - not creds - or not creds.scopes - or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes - ): - do_authentication(hass, config, conf, storage) - else: - do_setup(hass, config, conf) + creds = await hass.async_add_executor_job(storage.get) + if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes: + _LOGGER.debug("Importing configuration entry with credentials") + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "creds": creds, + }, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + assert isinstance(implementation, DeviceAuth) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope + if required_scope not in session.token.get("scope", []): + raise ConfigEntryAuthFailed( + "Required scopes are not available, reauth required" + ) + calendar_service = GoogleCalendarService(hass, session) + hass.data[DOMAIN][DATA_SERVICE] = calendar_service + + await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -def setup_services( +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_setup_services( hass: HomeAssistant, - hass_config: ConfigType, config: ConfigType, calendar_service: GoogleCalendarService, ) -> None: """Set up the service listeners.""" created_calendars = set() - calendars = load_config(hass.config.path(YAML_DEVICES)) + calendars = await hass.async_add_executor_job( + load_config, hass.config.path(YAML_DEVICES) + ) - def _found_calendar(call: ServiceCall) -> None: - """Check if we know about a calendar and generate PLATFORM_DISCOVER.""" + async def _found_calendar(call: ServiceCall) -> None: calendar = get_calendar_info(hass, call.data) calendar_id = calendar[CONF_CAL_ID] @@ -294,31 +230,33 @@ def setup_services( # Populate the yaml file with all discovered calendars if calendar_id not in calendars: calendars[calendar_id] = calendar - update_config(hass.config.path(YAML_DEVICES), calendar) + await hass.async_add_executor_job( + update_config, hass.config.path(YAML_DEVICES), calendar + ) else: # Prefer entity/name information from yaml, overriding api calendar = calendars[calendar_id] + async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar) - discovery.load_platform( - hass, - Platform.CALENDAR, - DOMAIN, - calendar, - hass_config, - ) + hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) - hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) - - def _scan_for_calendars(call: ServiceCall) -> None: + async def _scan_for_calendars(call: ServiceCall) -> None: """Scan for new calendars.""" - calendars = calendar_service.list_calendars() + try: + calendars = await calendar_service.async_list_calendars() + except ServerNotFoundError as err: + raise HomeAssistantError(str(err)) from err + tasks = [] for calendar in calendars: calendar[CONF_TRACK] = config[CONF_TRACK_NEW] - hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) + tasks.append( + hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) + ) + await asyncio.gather(*tasks) - hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) + hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) - def _add_event(call: ServiceCall) -> None: + async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" start = {} end = {} @@ -354,7 +292,7 @@ def setup_services( start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)} end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)} - calendar_service.create_event( + await calendar_service.async_create_event( call.data[EVENT_CALENDAR_ID], { "summary": call.data[EVENT_SUMMARY], @@ -366,20 +304,11 @@ def setup_services( # Only expose the add event service if we have the correct permissions if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write: - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA ) -def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None: - """Run the setup after we have everything configured.""" - calendar_service = hass.data[DOMAIN][DATA_SERVICE] - setup_services(hass, hass_config, config, calendar_service) - - # Fetch calendars from the API - hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None) - - def get_calendar_info( hass: HomeAssistant, calendar: Mapping[str, Any] ) -> dict[str, Any]: diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 1de6eb4e9aa..c279aefef88 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -2,20 +2,166 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable import datetime import logging from typing import Any from googleapiclient import discovery as google_discovery -from oauth2client.file import Storage +import oauth2client +from oauth2client.client import ( + Credentials, + DeviceFlowInfo, + FlowExchangeError, + OAuth2Credentials, + OAuth2DeviceCodeError, + OAuth2WebServerFlow, +) -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt +from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN + _LOGGER = logging.getLogger(__name__) - EVENT_PAGE_SIZE = 100 +EXCHANGE_TIMEOUT_SECONDS = 60 + + +class OAuthError(Exception): + """OAuth related error.""" + + +class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): + """OAuth implementation for Device Auth.""" + + def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None: + """Initialize InstalledAppAuth.""" + super().__init__( + hass, + DEVICE_AUTH_IMPL, + client_id, + client_secret, + oauth2client.GOOGLE_AUTH_URI, + oauth2client.GOOGLE_TOKEN_URI, + ) + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve a Google API Credentials object to Home Assistant token.""" + creds: Credentials = external_data["creds"] + return { + "access_token": creds.access_token, + "refresh_token": creds.refresh_token, + "scope": " ".join(creds.scopes), + "token_type": "Bearer", + "expires_in": creds.token_expiry.timestamp(), + } + + +class DeviceFlow: + """OAuth2 device flow for exchanging a code for an access token.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_flow: OAuth2WebServerFlow, + device_flow_info: DeviceFlowInfo, + ) -> None: + """Initialize DeviceFlow.""" + self._hass = hass + self._oauth_flow = oauth_flow + self._device_flow_info: DeviceFlowInfo = device_flow_info + self._exchange_task_unsub: CALLBACK_TYPE | None = None + + @property + def verification_url(self) -> str: + """Return the verification url that the user should visit to enter the code.""" + return self._device_flow_info.verification_url + + @property + def user_code(self) -> str: + """Return the code that the user should enter at the verification url.""" + return self._device_flow_info.user_code + + async def start_exchange_task( + self, finished_cb: Callable[[Credentials | None], Awaitable[None]] + ) -> None: + """Start the device auth exchange flow polling. + + The callback is invoked with the valid credentials or with None on timeout. + """ + _LOGGER.debug("Starting exchange flow") + assert not self._exchange_task_unsub + max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) + # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime + # object without tzinfo. For the comparison below to work, it needs one. + user_code_expiry = self._device_flow_info.user_code_expiry.replace( + tzinfo=datetime.timezone.utc + ) + expiration_time = min(user_code_expiry, max_timeout) + + def _exchange() -> Credentials: + return self._oauth_flow.step2_exchange( + device_flow_info=self._device_flow_info + ) + + async def _poll_attempt(now: datetime.datetime) -> None: + assert self._exchange_task_unsub + _LOGGER.debug("Attempting OAuth code exchange") + # Note: The callback is invoked with None when the device code has expired + creds: Credentials | None = None + if now < expiration_time: + try: + creds = await self._hass.async_add_executor_job(_exchange) + except FlowExchangeError: + _LOGGER.debug("Token not yet ready; trying again later") + return + self._exchange_task_unsub() + self._exchange_task_unsub = None + await finished_cb(creds) + + self._exchange_task_unsub = async_track_time_interval( + self._hass, + _poll_attempt, + datetime.timedelta(seconds=self._device_flow_info.interval), + ) + + +async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow: + """Create a new Device flow.""" + conf = hass.data[DOMAIN][DATA_CONFIG] + oauth_flow = OAuth2WebServerFlow( + client_id=conf[CONF_CLIENT_ID], + client_secret=conf[CONF_CLIENT_SECRET], + scope=conf[CONF_CALENDAR_ACCESS].scope, + redirect_uri="", + ) + try: + device_flow_info = await hass.async_add_executor_job( + oauth_flow.step1_get_device_and_user_codes + ) + except OAuth2DeviceCodeError as err: + raise OAuthError(str(err)) from err + return DeviceFlow(hass, oauth_flow, device_flow_info) + + +def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials: + """Convert a Home Assistant token to a Google API Credentials object.""" + conf = hass.data[DOMAIN][DATA_CONFIG] + return OAuth2Credentials( + access_token=token["access_token"], + client_id=conf[CONF_CLIENT_ID], + client_secret=conf[CONF_CLIENT_SECRET], + refresh_token=token["refresh_token"], + token_expiry=token["expires_at"], + token_uri=oauth2client.GOOGLE_TOKEN_URI, + scopes=[conf[CONF_CALENDAR_ACCESS].scope], + user_agent=None, + ) def _api_time_format(time: datetime.datetime | None) -> str | None: @@ -26,26 +172,44 @@ def _api_time_format(time: datetime.datetime | None) -> str | None: class GoogleCalendarService: """Calendar service interface to Google.""" - def __init__(self, hass: HomeAssistant, storage: Storage) -> None: + def __init__( + self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session + ) -> None: """Init the Google Calendar service.""" self._hass = hass - self._storage = storage + self._session = session - def _get_service(self) -> google_discovery.Resource: - """Get the calendar service from the storage file token.""" + async def _async_get_service(self) -> google_discovery.Resource: + """Get the calendar service with valid credetnails.""" + await self._session.async_ensure_token_valid() + creds = _async_google_creds(self._hass, self._session.token) return google_discovery.build( - "calendar", "v3", credentials=self._storage.get(), cache_discovery=False + "calendar", "v3", credentials=creds, cache_discovery=False ) - def list_calendars(self) -> list[dict[str, Any]]: + async def async_list_calendars( + self, + ) -> list[dict[str, Any]]: """Return the list of calendars the user has added to their list.""" - cal_list = self._get_service().calendarList() # pylint: disable=no-member - return cal_list.list().execute()["items"] + service = await self._async_get_service() - def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]: - """Create an event.""" - events = self._get_service().events() # pylint: disable=no-member - return events.insert(calendarId=calendar_id, body=event).execute() + def _list_calendars() -> list[dict[str, Any]]: + cal_list = service.calendarList() # pylint: disable=no-member + return cal_list.list().execute()["items"] + + return await self._hass.async_add_executor_job(_list_calendars) + + async def async_create_event( + self, calendar_id: str, event: dict[str, Any] + ) -> dict[str, Any]: + """Return the list of calendars the user has added to their list.""" + service = await self._async_get_service() + + def _create_event() -> dict[str, Any]: + events = service.events() # pylint: disable=no-member + return events.insert(calendarId=calendar_id, body=event).execute() + + return await self._hass.async_add_executor_job(_create_event) async def async_list_events( self, @@ -56,33 +220,20 @@ class GoogleCalendarService: page_token: str | None = None, ) -> tuple[list[dict[str, Any]], str | None]: """Return the list of events.""" - return await self._hass.async_add_executor_job( - self.list_events, - calendar_id, - start_time, - end_time, - search, - page_token, - ) + service = await self._async_get_service() - def list_events( - self, - calendar_id: str, - start_time: datetime.datetime | None = None, - end_time: datetime.datetime | None = None, - search: str | None = None, - page_token: str | None = None, - ) -> tuple[list[dict[str, Any]], str | None]: - """Return the list of events.""" - events = self._get_service().events() # pylint: disable=no-member - result = events.list( - calendarId=calendar_id, - timeMin=_api_time_format(start_time if start_time else dt.now()), - timeMax=_api_time_format(end_time), - q=search, - maxResults=EVENT_PAGE_SIZE, - pageToken=page_token, - singleEvents=True, # Flattens recurring events - orderBy="startTime", - ).execute() - return (result["items"], result.get("nextPageToken")) + def _list_events() -> tuple[list[dict[str, Any]], str | None]: + events = service.events() # pylint: disable=no-member + result = events.list( + calendarId=calendar_id, + timeMin=_api_time_format(start_time if start_time else dt.now()), + timeMax=_api_time_format(end_time), + q=search, + maxResults=EVENT_PAGE_SIZE, + pageToken=page_token, + singleEvents=True, # Flattens recurring events + orderBy="startTime", + ).execute() + return (result["items"], result.get("nextPageToken")) + + return await self._hass.async_add_executor_job(_list_events) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 282c80988e4..666096fd8c3 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -14,11 +14,13 @@ from homeassistant.components.calendar import ( calculate_offset, is_offset_reached, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from . import ( @@ -29,8 +31,10 @@ from . import ( DATA_SERVICE, DEFAULT_CONF_OFFSET, DOMAIN, + SERVICE_SCAN_CALENDARS, ) from .api import GoogleCalendarService +from .const import DISCOVER_CALENDAR _LOGGER = logging.getLogger(__name__) @@ -48,19 +52,39 @@ TRANSPARENCY = "transparency" OPAQUE = "opaque" -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - disc_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the calendar platform for event devices.""" - if disc_info is None: - return + """Set up the google calendar platform.""" - if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]): - return + @callback + def async_discover(discovery_info: dict[str, Any]) -> None: + _async_setup_entities( + hass, + entry, + async_add_entities, + discovery_info, + ) + entry.async_on_unload( + async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover) + ) + + # Look for any new calendars + try: + await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True) + except HomeAssistantError as err: + # This can happen if there's a connection error during setup. + raise PlatformNotReady(str(err)) from err + + +@callback +def _async_setup_entities( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + disc_info: dict[str, Any], +) -> None: calendar_service = hass.data[DOMAIN][DATA_SERVICE] entities = [] for data in disc_info[CONF_ENTITIES]: @@ -74,7 +98,7 @@ def setup_platform( ) entities.append(entity) - add_entities(entities, True) + async_add_entities(entities, True) class GoogleCalendarEventDevice(CalendarEventDevice): @@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice): return event_list @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data.""" try: - items, _ = self._calendar_service.list_events( + items, _ = await self._calendar_service.async_list_events( self._calendar_id, search=self._search ) except ServerNotFoundError as err: diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py new file mode 100644 index 00000000000..c70dd83fcae --- /dev/null +++ b/homeassistant/components/google/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Google integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from oauth2client.client import Credentials + +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .api import DeviceFlow, OAuthError, async_create_device_flow +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Calendars OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Set up instance.""" + super().__init__() + self._reauth = False + self._device_flow: DeviceFlow | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: + """Import existing auth from Nest.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + assert len(implementations) == 1 + self.flow_impl = list(implementations.values())[0] + self.external_data = info + return await super().async_step_creation(info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle external yaml configuration.""" + if not self._reauth and self._async_current_entries(): + return self.async_abort(reason="already_configured") + return await super().async_step_user(user_input) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create an entry for auth.""" + # The default behavior from the parent class is to redirect the + # user with an external step. When using the device flow, we instead + # prompt the user to visit a URL and enter a code. The device flow + # background task will poll the exchange endpoint to get valid + # creds or until a timeout is complete. + if user_input is not None: + return self.async_show_progress_done(next_step_id="creation") + + if not self._device_flow: + _LOGGER.debug("Creating DeviceAuth flow") + try: + device_flow = await async_create_device_flow(self.hass) + except OAuthError as err: + _LOGGER.error("Error initializing device flow: %s", str(err)) + return self.async_abort(reason="oauth_error") + self._device_flow = device_flow + + async def _exchange_finished(creds: Credentials | None) -> None: + self.external_data = {"creds": creds} # is None on timeout/expiration + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure( + flow_id=self.flow_id, user_input={} + ) + ) + + await device_flow.start_exchange_task(_exchange_finished) + + return self.async_show_progress( + step_id="auth", + description_placeholders={ + "url": self._device_flow.verification_url, + "user_code": self._device_flow.user_code, + }, + progress_action="exchange", + ) + + async def async_step_creation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle external yaml configuration.""" + if self.external_data.get("creds") is None: + return self.async_abort(reason="code_expired") + return await super().async_step_creation(user_input) + + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + existing_entries = self._async_current_entries() + if existing_entries: + assert len(existing_entries) == 1 + entry = existing_entries[0] + self.hass.config_entries.async_update_entry(entry, data=data) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self.flow_impl.name, data=data) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth = True + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py new file mode 100644 index 00000000000..d5cdabb0638 --- /dev/null +++ b/homeassistant/components/google/const.py @@ -0,0 +1,30 @@ +"""Constants for google integration.""" +from __future__ import annotations + +from enum import Enum + +DOMAIN = "google" +DEVICE_AUTH_IMPL = "device_auth" + +CONF_CALENDAR_ACCESS = "calendar_access" +DATA_CALENDARS = "calendars" +DATA_SERVICE = "service" +DATA_CONFIG = "config" + +DISCOVER_CALENDAR = "google_discover_calendar" + + +class FeatureAccess(Enum): + """Class to represent different access scopes.""" + + read_only = "https://www.googleapis.com/auth/calendar.readonly" + read_write = "https://www.googleapis.com/auth/calendar" + + def __init__(self, scope: str) -> None: + """Init instance.""" + self._scope = scope + + @property + def scope(self) -> str: + """Google calendar scope for the feature.""" + return self._scope diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 53b85ef7aa2..589ecb25b21 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -1,6 +1,8 @@ { "domain": "google", "name": "Google Calendars", + "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/calendar.google/", "requirements": [ "google-api-python-client==2.38.0", diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json new file mode 100644 index 00000000000..8208b7bebc9 --- /dev/null +++ b/homeassistant/components/google/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Nest integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google 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%]", + "code_expired": "Authentication code expired or credential setup is invalid, please try again.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "progress": { + "exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}" + } + } +} diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json new file mode 100644 index 00000000000..51d1ad9aab8 --- /dev/null +++ b/homeassistant/components/google/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "code_expired": "Authentication code expired, please try again.", + "invalid_access_token": "Invalid access token", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth_error": "Received invalid token data.", + "reauth_successful": "Re-authentication was successful" + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "progress": { + "exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}" + }, + "step": { + "auth": { + "title": "Link Google Account" + }, + "pick_implementation": { + "title": "Pick Authentication Method" + }, + "reauth_confirm": { + "description": "The Nest 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 99e5cf7caf3..6d572efb0c7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -125,6 +125,7 @@ FLOWS = [ "goalzero", "gogogate2", "goodwe", + "google", "google_travel_time", "gpslogger", "gree", diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index f3d8f9a28b9..8efeac9983d 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.dt import utcnow +from tests.common import MockConfigEntry + ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE ApiResult = Callable[[dict[str, Any]], None] @@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]: yield storage +@pytest.fixture +async def config_entry(token_scopes: list[str]) -> MockConfigEntry: + """Fixture to create a config entry for the integration.""" + token_expiry = utcnow() + datetime.timedelta(days=7) + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": "device_auth", + "token": { + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "scope": " ".join(token_scopes), + "token_type": "Bearer", + "expires_at": token_expiry.timestamp(), + }, + }, + ) + + @pytest.fixture async def mock_token_read( hass: HomeAssistant, diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 34227ae02b1..baa09d0b88f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -53,10 +53,15 @@ TEST_EVENT = { @pytest.fixture(autouse=True) def mock_test_setup( - mock_calendars_yaml, test_api_calendar, mock_calendars_list, mock_token_read + hass, + mock_calendars_yaml, + test_api_calendar, + mock_calendars_list, + config_entry, ): """Fixture that pulls in the default fixtures for tests in this file.""" mock_calendars_list({"items": [test_api_calendar]}) + config_entry.add_to_hass(hass) return @@ -300,12 +305,11 @@ async def test_update_error( assert state.name == TEST_ENTITY_NAME assert state.state == "on" - # Advance time to avoid throttling + # Advance time beyond update/throttle point now += datetime.timedelta(minutes=30) with patch( "homeassistant.components.google.api.google_discovery.build" ) as mock, patch("homeassistant.util.utcnow", return_value=now): - mock.return_value.events.return_value.list.return_value.execute.return_value = { "items": [ { @@ -417,3 +421,19 @@ async def test_opaque_event( assert response.status == HTTPStatus.OK events = await response.json() assert (len(events) > 0) == expect_visible_event + + +async def test_scan_calendar_error( + hass, + calendar_resource, + component_setup, + test_api_calendar, +): + """Test that the calendar update handles a server error.""" + with patch( + "homeassistant.components.google.api.google_discovery.build", + side_effect=httplib2.ServerNotFoundError("unit test"), + ): + assert await component_setup() + + assert not hass.states.get(TEST_ENTITY) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py new file mode 100644 index 00000000000..a5467b95dcb --- /dev/null +++ b/tests/components/google/test_config_flow.py @@ -0,0 +1,350 @@ +"""Test the google config flow.""" + +import datetime +from unittest.mock import Mock, patch + +from oauth2client.client import ( + FlowExchangeError, + OAuth2Credentials, + OAuth2DeviceCodeError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.google.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .conftest import ComponentSetup, YieldFixture + +from tests.common import MockConfigEntry, async_fire_time_changed + +CODE_CHECK_INTERVAL = 1 +CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) + + +@pytest.fixture(autouse=True) +async def request_setup(current_request_with_host) -> None: + """Request setup.""" + return + + +@pytest.fixture +async def code_expiration_delta() -> datetime.timedelta: + """Fixture for code expiration time, defaulting to the future.""" + return datetime.timedelta(minutes=3) + + +@pytest.fixture +async def mock_code_flow( + code_expiration_delta: datetime.timedelta, +) -> YieldFixture[Mock]: + """Fixture for initiating OAuth flow.""" + with patch( + "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + ) as mock_flow: + mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta + mock_flow.return_value.interval = CODE_CHECK_INTERVAL + yield mock_flow + + +@pytest.fixture +async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: + """Fixture for mocking out the exchange for credentials.""" + with patch( + "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds + ) as mock: + yield mock + + +async def fire_alarm(hass, point_in_time): + """Fire an alarm and wait for callbacks to run.""" + with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + async_fire_time_changed(hass, point_in_time) + await hass.async_block_till_done() + + +async def test_full_flow( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + component_setup: ComponentSetup, +) -> None: + """Test successful creds setup.""" + assert await component_setup() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + + assert result.get("type") == "create_entry" + assert result.get("title") == "Configuration.yaml" + assert "data" in result + data = result["data"] + assert "token" in data + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": "device_auth", + "token": { + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + } + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + +async def test_code_error( + hass: HomeAssistant, + mock_code_flow: Mock, + component_setup: ComponentSetup, +) -> None: + """Test successful creds setup.""" + assert await component_setup() + + with patch( + "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + side_effect=OAuth2DeviceCodeError("Test Failure"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "abort" + assert result.get("reason") == "oauth_error" + + +@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)]) +async def test_expired_after_exchange( + hass: HomeAssistant, + mock_code_flow: Mock, + component_setup: ComponentSetup, +) -> None: + """Test successful creds setup.""" + assert await component_setup() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "code_expired" + + +async def test_exchange_error( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + component_setup: ComponentSetup, +) -> None: + """Test an error while exchanging the code for credentials.""" + assert await component_setup() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + # Run one tick to invoke the credential exchange check + now = utcnow() + with patch( + "oauth2client.client.OAuth2WebServerFlow.step2_exchange", + side_effect=FlowExchangeError(), + ): + now += CODE_CHECK_ALARM_TIMEDELTA + await fire_alarm(hass, now) + await hass.async_block_till_done() + + # Status has not updated, will retry + result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + + # Run another tick, which attempts credential exchange again + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + now += CODE_CHECK_ALARM_TIMEDELTA + await fire_alarm(hass, now) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + + assert result.get("type") == "create_entry" + assert result.get("title") == "Configuration.yaml" + assert "data" in result + data = result["data"] + assert "token" in data + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": "device_auth", + "token": { + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + } + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + +async def test_existing_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + component_setup: ComponentSetup, +) -> None: + """Test can't configure when config entry already exists.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert await component_setup() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "abort" + assert result.get("reason") == "already_configured" + + +async def test_missing_configuration( + hass: HomeAssistant, +) -> None: + """Test can't configure when config entry already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_configuration" + + +async def test_import_config_entry_from_existing_token( + hass: HomeAssistant, + mock_token_read: None, + component_setup: ComponentSetup, +) -> None: + """Test setup with an existing token file.""" + assert await component_setup() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + data = entries[0].data + assert "token" in data + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": "device_auth", + "token": { + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + } + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + component_setup: ComponentSetup, +) -> None: + """Test can't configure when config entry already exists.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": "device_auth", + "token": {"access_token": "OLD_ACCESS_TOKEN"}, + }, + ) + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert await component_setup() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + + assert result.get("type") == "abort" + assert result.get("reason") == "reauth_successful" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + data = entries[0].data + assert "token" in data + data["token"].pop("expires_at") + data["token"].pop("expires_in") + assert data == { + "auth_implementation": "device_auth", + "token": { + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "scope": "https://www.googleapis.com/auth/calendar", + "token_type": "Bearer", + }, + } + + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 0f02e20c735..6fc575ba03d 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -6,11 +6,6 @@ import datetime from typing import Any from unittest.mock import Mock, call, patch -from oauth2client.client import ( - FlowExchangeError, - OAuth2Credentials, - OAuth2DeviceCodeError, -) import pytest from homeassistant.components.google import ( @@ -18,6 +13,7 @@ from homeassistant.components.google import ( SERVICE_ADD_EVENT, SERVICE_SCAN_CALENDARS, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.util.dt import utcnow @@ -30,73 +26,13 @@ from .conftest import ( TEST_YAML_ENTITY_NAME, ApiResult, ComponentSetup, - YieldFixture, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry # Typing helpers HassApi = Callable[[], Awaitable[dict[str, Any]]] -CODE_CHECK_INTERVAL = 1 -CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) - - -@pytest.fixture -async def code_expiration_delta() -> datetime.timedelta: - """Fixture for code expiration time, defaulting to the future.""" - return datetime.timedelta(minutes=3) - - -@pytest.fixture -async def mock_code_flow( - code_expiration_delta: datetime.timedelta, -) -> YieldFixture[Mock]: - """Fixture for initiating OAuth flow.""" - with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", - ) as mock_flow: - mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta - mock_flow.return_value.interval = CODE_CHECK_INTERVAL - yield mock_flow - - -@pytest.fixture -async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: - """Fixture for mocking out the exchange for credentials.""" - with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds - ) as mock: - yield mock - - -@pytest.fixture -async def mock_notification() -> YieldFixture[Mock]: - """Fixture for capturing persistent notifications.""" - with patch("homeassistant.components.persistent_notification.create") as mock: - yield mock - - -async def fire_alarm(hass, point_in_time): - """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): - async_fire_time_changed(hass, point_in_time) - await hass.async_block_till_done() - - -@pytest.mark.parametrize("config", [{}]) -async def test_setup_config_empty( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_notification: Mock, -): - """Test setup component with an empty configuruation.""" - assert await component_setup() - - mock_notification.assert_not_called() - - assert not hass.states.get(TEST_YAML_ENTITY) - def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" @@ -108,118 +44,29 @@ def assert_state(actual: State | None, expected: State | None) -> None: assert actual.attributes == expected.attributes -async def test_init_success( +@pytest.fixture +def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> MockConfigEntry: + """Fixture to initialize the config entry.""" + config_entry.add_to_hass(hass) + + +async def test_unload_entry( hass: HomeAssistant, - mock_code_flow: Mock, - mock_exchange: Mock, - mock_notification: Mock, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_calendars_yaml: None, component_setup: ComponentSetup, + setup_config_entry: MockConfigEntry, ) -> None: - """Test successful creds setup.""" - mock_calendars_list({"items": [test_api_calendar]}) - assert await component_setup() + """Test load and unload of a ConfigEntry.""" + await component_setup() - # Run one tick to invoke the credential exchange check - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(TEST_YAML_ENTITY) - assert state - assert state.name == TEST_YAML_ENTITY_NAME - assert state.state == STATE_OFF - - mock_notification.assert_called() - assert "We are all setup now" in mock_notification.call_args[0][1] - - -async def test_code_error( - hass: HomeAssistant, - mock_code_flow: Mock, - component_setup: ComponentSetup, - mock_notification: Mock, -) -> None: - """Test loading the integration with no existing credentials.""" - - with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", - side_effect=OAuth2DeviceCodeError("Test Failure"), - ): - assert await component_setup() - - assert not hass.states.get(TEST_YAML_ENTITY) - - mock_notification.assert_called() - assert "Error: Test Failure" in mock_notification.call_args[0][1] - - -@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)]) -async def test_expired_after_exchange( - hass: HomeAssistant, - mock_code_flow: Mock, - component_setup: ComponentSetup, - mock_notification: Mock, -) -> None: - """Test loading the integration with no existing credentials.""" - - assert await component_setup() - - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) - - assert not hass.states.get(TEST_YAML_ENTITY) - - mock_notification.assert_called() - assert ( - "Authentication code expired, please restart Home-Assistant and try again" - in mock_notification.call_args[0][1] - ) - - -async def test_exchange_error( - hass: HomeAssistant, - mock_code_flow: Mock, - component_setup: ComponentSetup, - mock_notification: Mock, -) -> None: - """Test an error while exchanging the code for credentials.""" - - with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", - side_effect=FlowExchangeError(), - ): - assert await component_setup() - - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) - - assert not hass.states.get(TEST_YAML_ENTITY) - - mock_notification.assert_called() - assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1] - - -async def test_existing_token( - hass: HomeAssistant, - mock_token_read: None, - component_setup: ComponentSetup, - mock_calendars_yaml: None, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_notification: Mock, -) -> None: - """Test setup with an existing token file.""" - mock_calendars_list({"items": [test_api_calendar]}) - assert await component_setup() - - state = hass.states.get(TEST_YAML_ENTITY) - assert state - assert state.name == TEST_YAML_ENTITY_NAME - assert state.state == STATE_OFF - - mock_notification.assert_not_called() + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( @@ -228,80 +75,61 @@ async def test_existing_token( async def test_existing_token_missing_scope( hass: HomeAssistant, token_scopes: list[str], - mock_token_read: None, component_setup: ComponentSetup, - mock_calendars_yaml: None, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_notification: Mock, - mock_code_flow: Mock, - mock_exchange: Mock, + config_entry: MockConfigEntry, ) -> None: """Test setup where existing token does not have sufficient scopes.""" - mock_calendars_list({"items": [test_api_calendar]}) + config_entry.add_to_hass(hass) assert await component_setup() - # Run one tick to invoke the credential exchange check - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) - assert len(mock_exchange.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR - state = hass.states.get(TEST_YAML_ENTITY) - assert state - assert state.name == TEST_YAML_ENTITY_NAME - assert state.state == STATE_OFF - - # No notifications on success - mock_notification.assert_called() - assert "We are all setup now" in mock_notification.call_args[0][1] + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]]) async def test_calendar_yaml_missing_required_fields( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, - mock_notification: Mock, + setup_config_entry: MockConfigEntry, ) -> None: """Test setup with a missing schema fields, ignores the error and continues.""" assert await component_setup() assert not hass.states.get(TEST_YAML_ENTITY) - mock_notification.assert_not_called() - @pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]]) async def test_invalid_calendar_yaml( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, - mock_notification: Mock, + setup_config_entry: MockConfigEntry, ) -> None: - """Test setup with missing entity id fields fails to setup the integration.""" - + """Test setup with missing entity id fields fails to setup the config entry.""" # Integration fails to setup - assert not await component_setup() + assert await component_setup() + + # XXX No config entries assert not hass.states.get(TEST_YAML_ENTITY) - mock_notification.assert_not_called() - async def test_calendar_yaml_error( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], - mock_notification: Mock, + setup_config_entry: MockConfigEntry, ) -> None: """Test setup with yaml file not found.""" - mock_calendars_list({"items": [test_api_calendar]}) with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()): @@ -344,12 +172,12 @@ async def test_calendar_yaml_error( ) async def test_track_new( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_calendars_yaml: None, expected_state: State, + setup_config_entry: MockConfigEntry, ) -> None: """Test behavior of configuration.yaml settings for tracking new calendars not in the config.""" @@ -363,11 +191,11 @@ async def test_track_new( @pytest.mark.parametrize("calendars_config", [[]]) async def test_found_calendar_from_api( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + setup_config_entry: MockConfigEntry, ) -> None: """Test finding a calendar from the API.""" @@ -402,13 +230,13 @@ async def test_found_calendar_from_api( ) async def test_calendar_config_track_new( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], calendars_config_track: bool, expected_state: State, + setup_config_entry: MockConfigEntry, ) -> None: """Test calendar config that overrides whether or not a calendar is tracked.""" @@ -421,11 +249,11 @@ async def test_calendar_config_track_new( async def test_add_event( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_insert_event: Mock, + setup_config_entry: MockConfigEntry, ) -> None: """Test service call that adds an event.""" @@ -471,7 +299,6 @@ async def test_add_event( ) async def test_add_event_date_in_x( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], @@ -479,6 +306,7 @@ async def test_add_event_date_in_x( date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, + setup_config_entry: MockConfigEntry, ) -> None: """Test service call that adds an event with various time ranges.""" @@ -514,10 +342,10 @@ async def test_add_event_date_in_x( async def test_add_event_date( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, mock_insert_event: Mock, + setup_config_entry: MockConfigEntry, ) -> None: """Test service call that sets a date range.""" @@ -554,11 +382,11 @@ async def test_add_event_date( async def test_add_event_date_time( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_insert_event: Mock, + setup_config_entry: MockConfigEntry, ) -> None: """Test service call that adds an event with a date time range.""" @@ -601,10 +429,10 @@ async def test_add_event_date_time( async def test_scan_calendars( hass: HomeAssistant, - mock_token_read: None, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + setup_config_entry: MockConfigEntry, ) -> None: """Test finding a calendar from the API."""