diff --git a/.coveragerc b/.coveragerc index 9c1c8733ab4..a7baa17e852 100644 --- a/.coveragerc +++ b/.coveragerc @@ -407,7 +407,6 @@ omit = homeassistant/components/goodwe/number.py homeassistant/components/goodwe/select.py homeassistant/components/goodwe/sensor.py - homeassistant/components/google/__init__.py homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_pubsub/__init__.py diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0dfacb13e74..05bfb490cb8 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -194,6 +194,7 @@ def do_authentication(hass, hass_config, config): def step2_exchange(now): """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. @@ -208,6 +209,7 @@ def do_authentication(hass, hass_config, config): notification_id=NOTIFICATION_ID, ) listener() + return try: credentials = oauth.step2_exchange(device_flow_info=dev_flow) @@ -247,9 +249,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: token_file = hass.config.path(TOKEN_FILE) if not os.path.isfile(token_file): + _LOGGER.debug("Token file does not exist, authenticating for first time") do_authentication(hass, config, conf) else: - if not check_correct_scopes(token_file, conf): + if not check_correct_scopes(hass, token_file, conf): + _LOGGER.debug("Existing scopes are not sufficient, re-authenticating") do_authentication(hass, config, conf) else: do_setup(hass, config, conf) @@ -257,17 +261,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def check_correct_scopes(token_file, config): +def check_correct_scopes(hass, token_file, config): """Check for the correct scopes in file.""" - with open(token_file, encoding="utf8") as tokenfile: - contents = tokenfile.read() - - # Check for quoted scope as our scopes can be subsets of other scopes - target_scope = f'"{config.get(CONF_CALENDAR_ACCESS).scope}"' - if target_scope not in contents: - _LOGGER.warning("Please re-authenticate with Google") - return False - return True + creds = Storage(token_file).get() + if not creds or not creds.scopes: + return False + target_scope = config[CONF_CALENDAR_ACCESS].scope + return target_scope in creds.scopes def setup_services( @@ -364,6 +364,7 @@ def setup_services( def do_setup(hass, hass_config, config): """Run the setup after we have everything configured.""" + _LOGGER.debug("Setting up integration") # Load calendars the user has configured hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES)) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 20cb13130ec..59de9b2cddf 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -1,10 +1,20 @@ """Test configuration and mocks for the google integration.""" -from unittest.mock import patch +from collections.abc import Callable +from typing import Any, Generator, TypeVar +from unittest.mock import Mock, patch import pytest +from homeassistant.components.google import GoogleCalendarService + +ApiResult = Callable[[dict[str, Any]], None] +T = TypeVar("T") +YieldFixture = Generator[T, None, None] + + +CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" TEST_CALENDAR = { - "id": "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com", + "id": CALENDAR_ID, "etag": '"3584134138943410"', "timeZone": "UTC", "accessRole": "reader", @@ -34,3 +44,45 @@ def mock_next_event(): ) with patch_google_cal as google_cal_data: yield google_cal_data + + +@pytest.fixture +def mock_events_list( + google_service: GoogleCalendarService, +) -> Callable[[dict[str, Any]], None]: + """Fixture to construct a fake event list API response.""" + + def _put_result(response: dict[str, Any]) -> None: + google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = ( + response + ) + return + + return _put_result + + +@pytest.fixture +def mock_calendars_list( + google_service: GoogleCalendarService, +) -> ApiResult: + """Fixture to construct a fake calendar list API response.""" + + def _put_result(response: dict[str, Any]) -> None: + google_service.return_value.get.return_value.calendarList.return_value.list.return_value.execute.return_value = ( + response + ) + return + + return _put_result + + +@pytest.fixture +def mock_insert_event( + google_service: GoogleCalendarService, +) -> Mock: + """Fixture to create a mock to capture new events added to the API.""" + insert_mock = Mock() + google_service.return_value.get.return_value.events.return_value.insert = ( + insert_mock + ) + return insert_mock diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 01bd179e2ea..0ee257788dd 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable import copy from http import HTTPStatus from typing import Any @@ -22,7 +21,6 @@ from homeassistant.components.google import ( CONF_TRACK, DEVICE_SCHEMA, SERVICE_SCAN_CALENDARS, - GoogleCalendarService, do_setup, ) from homeassistant.const import STATE_OFF, STATE_ON @@ -358,21 +356,6 @@ async def test_http_event_api_failure(hass, hass_client, google_service): assert events == [] -@pytest.fixture -def mock_events_list( - google_service: GoogleCalendarService, -) -> Callable[[dict[str, Any]], None]: - """Fixture to construct a fake event list API response.""" - - def _put_result(response: dict[str, Any]) -> None: - google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = ( - response - ) - return - - return _put_result - - async def test_http_api_event(hass, hass_client, google_service, mock_events_list): """Test querying the API and fetching events from the server.""" now = dt_util.now() diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index d90efa29f6c..c3754511b04 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -1,67 +1,497 @@ """The tests for the Google Calendar component.""" -from unittest.mock import patch +from collections.abc import Awaitable, Callable +import datetime +from typing import Any +from unittest.mock import Mock, call, mock_open, patch +from oauth2client.client import ( + FlowExchangeError, + OAuth2Credentials, + OAuth2DeviceCodeError, +) import pytest +import yaml -import homeassistant.components.google as google -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.components.google import ( + DOMAIN, + SERVICE_ADD_EVENT, + GoogleCalendarService, +) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, STATE_OFF +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from .conftest import CALENDAR_ID, ApiResult, YieldFixture + +from tests.common import async_fire_time_changed + +# Typing helpers +ComponentSetup = Callable[[], Awaitable[bool]] +HassApi = Callable[[], Awaitable[dict[str, Any]]] + +CODE_CHECK_INTERVAL = 1 +CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) -@pytest.fixture(name="google_setup") -def mock_google_setup(hass): - """Mock the google set up functions.""" - p_auth = patch( - "homeassistant.components.google.do_authentication", side_effect=google.do_setup +@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 token_scopes() -> list[str]: + """Fixture for scopes used during test.""" + return ["https://www.googleapis.com/auth/calendar"] + + +@pytest.fixture +async def creds(token_scopes: list[str]) -> OAuth2Credentials: + """Fixture that defines creds used in the test.""" + token_expiry = utcnow() + datetime.timedelta(days=7) + return OAuth2Credentials( + access_token="ACCESS_TOKEN", + client_id="client-id", + client_secret="client-secret", + refresh_token="REFRESH_TOKEN", + token_expiry=token_expiry, + token_uri="http://example.com", + user_agent="n/a", + scopes=token_scopes, ) - p_service = patch("homeassistant.components.google.GoogleCalendarService.get") - p_discovery = patch("homeassistant.components.google.discovery.load_platform") - p_load = patch("homeassistant.components.google.load_config", return_value={}) - p_save = patch("homeassistant.components.google.update_config") - with p_auth, p_load, p_service, p_discovery, p_save: + +@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(autouse=True) +async def mock_token_write(hass: HomeAssistant) -> None: + """Fixture to avoid writing token files to disk.""" + with patch( + "homeassistant.components.google.os.path.isfile", return_value=True + ), patch("homeassistant.components.google.Storage.put"): yield -async def test_setup_component(hass, google_setup): - """Test setup component.""" - config = {"google": {CONF_CLIENT_ID: "id", CONF_CLIENT_SECRET: "secret"}} - - assert await async_setup_component(hass, "google", config) +@pytest.fixture +async def mock_token_read( + hass: HomeAssistant, + creds: OAuth2Credentials, +) -> None: + """Fixture to populate an existing token file.""" + with patch("homeassistant.components.google.Storage.get", return_value=creds): + yield -async def test_get_calendar_info(hass, test_calendar): - """Test getting the calendar info.""" - calendar_info = await hass.async_add_executor_job( - google.get_calendar_info, hass, test_calendar - ) - assert calendar_info == { - "cal_id": "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com", - "entities": [ - { - "device_id": "we_are_we_are_a_test_calendar", - "name": "We are, we are, a... Test Calendar", - "track": True, - "ignore_availability": True, - } - ], - } - - -async def test_found_calendar(hass, google_setup, mock_next_event, test_calendar): - """Test when a calendar is found.""" - config = { - "google": { - CONF_CLIENT_ID: "id", - CONF_CLIENT_SECRET: "secret", - "track_new_calendar": True, +@pytest.fixture +async def calendars_config() -> list[dict[str, Any]]: + """Fixture for tests to override default calendar configuration.""" + return [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "backyard_light", + "name": "Backyard Light", + "search": "#Backyard", + "track": True, + } + ], } - } - assert await async_setup_component(hass, "google", config) - assert hass.data[google.DATA_INDEX] == {} + ] + + +@pytest.fixture +async def mock_calendars_yaml( + hass: HomeAssistant, + calendars_config: list[dict[str, Any]], +) -> None: + """Fixture that prepares the calendars.yaml file.""" + mocked_open_function = mock_open(read_data=yaml.dump(calendars_config)) + with patch("homeassistant.components.google.open", mocked_open_function): + yield + + +@pytest.fixture +async def mock_notification() -> YieldFixture[Mock]: + """Fixture for capturing persistent notifications.""" + with patch("homeassistant.components.persistent_notification.create") as mock: + yield mock + + +@pytest.fixture +async def config() -> dict[str, Any]: + """Fixture for overriding component config.""" + return {DOMAIN: {CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-ecret"}} + + +@pytest.fixture +async def component_setup( + hass: HomeAssistant, config: dict[str, Any] +) -> ComponentSetup: + """Fixture for setting up the integration.""" + + async def _setup_func() -> bool: + result = await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + return result + + return _setup_func + + +@pytest.fixture +async def google_service() -> YieldFixture[GoogleCalendarService]: + """Fixture to capture service calls.""" + with patch("homeassistant.components.google.GoogleCalendarService") as mock, patch( + "homeassistant.components.google.calendar.GoogleCalendarService", 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("calendar.backyard_light") + + +async def test_init_success( + hass: HomeAssistant, + google_service: GoogleCalendarService, + mock_code_flow: Mock, + mock_exchange: Mock, + mock_notification: Mock, + mock_calendars_yaml: None, + component_setup: ComponentSetup, +) -> None: + """Test successful creds setup.""" + assert await component_setup() + + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + + state = hass.states.get("calendar.backyard_light") + assert state + assert state.name == "Backyard Light" + 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("calendar.backyard_light") + + 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("calendar.backyard_light") + + 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("calendar.backyard_light") + + 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, + google_service: GoogleCalendarService, + mock_calendars_yaml: None, + mock_notification: Mock, +) -> None: + """Test setup with an existing token file.""" + assert await component_setup() + + state = hass.states.get("calendar.backyard_light") + assert state + assert state.name == "Backyard Light" + assert state.state == STATE_OFF + + mock_notification.assert_not_called() + + +@pytest.mark.parametrize( + "token_scopes", ["https://www.googleapis.com/auth/calendar.readonly"] +) +async def test_existing_token_missing_scope( + hass: HomeAssistant, + token_scopes: list[str], + mock_token_read: None, + component_setup: ComponentSetup, + google_service: GoogleCalendarService, + mock_calendars_yaml: None, + mock_notification: Mock, + mock_code_flow: Mock, + mock_exchange: Mock, +) -> None: + """Test setup where existing token does not have sufficient scopes.""" + 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 + + state = hass.states.get("calendar.backyard_light") + assert state + assert state.name == "Backyard Light" + 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] + + +@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, + google_service: GoogleCalendarService, + calendars_config: list[dict[str, Any]], + mock_calendars_yaml: None, + mock_notification: Mock, +) -> None: + """Test setup with a missing schema fields, ignores the error and continues.""" + assert await component_setup() + + assert not hass.states.get("calendar.backyard_light") + + 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, + google_service: GoogleCalendarService, + calendars_config: list[dict[str, Any]], + mock_calendars_yaml: None, + mock_notification: Mock, +) -> None: + """Test setup with missing entity id fields fails to setup the integration.""" + + # Integration fails to setup + assert not await component_setup() + + assert not hass.states.get("calendar.backyard_light") + + mock_notification.assert_not_called() + + +async def test_found_calendar_from_api( + hass: HomeAssistant, + mock_token_read: None, + component_setup: ComponentSetup, + google_service: GoogleCalendarService, + mock_calendars_list: ApiResult, + test_calendar: dict[str, Any], +) -> None: + """Test finding a calendar from the API.""" + + mock_calendars_list({"items": [test_calendar]}) + + mocked_open_function = mock_open(read_data=yaml.dump([])) + with patch("homeassistant.components.google.open", mocked_open_function): + assert await component_setup() + + state = hass.states.get("calendar.we_are_we_are_a_test_calendar") + assert state + assert state.name == "We are, we are, a... Test Calendar" + assert state.state == STATE_OFF + + +async def test_add_event( + hass: HomeAssistant, + mock_token_read: None, + component_setup: ComponentSetup, + google_service: GoogleCalendarService, + mock_calendars_list: ApiResult, + test_calendar: dict[str, Any], + mock_insert_event: Mock, +) -> None: + """Test service call that adds an event.""" + + assert await component_setup() await hass.services.async_call( - "google", google.SERVICE_FOUND_CALENDARS, test_calendar, blocking=True + DOMAIN, + SERVICE_ADD_EVENT, + { + "calendar_id": CALENDAR_ID, + "summary": "Summary", + "description": "Description", + }, + blocking=True, + ) + mock_insert_event.assert_called() + assert mock_insert_event.mock_calls[0] == call( + calendarId=CALENDAR_ID, + body={ + "summary": "Summary", + "description": "Description", + "start": {}, + "end": {}, + }, ) - assert hass.data[google.DATA_INDEX].get(test_calendar["id"]) is not None + +@pytest.mark.parametrize( + "date_fields,start_timedelta,end_timedelta", + [ + ( + {"in": {"days": 3}}, + datetime.timedelta(days=3), + datetime.timedelta(days=4), + ), + ( + {"in": {"weeks": 1}}, + datetime.timedelta(days=7), + datetime.timedelta(days=8), + ), + ( + { + "start_date": datetime.date.today().isoformat(), + "end_date": ( + datetime.date.today() + datetime.timedelta(days=2) + ).isoformat(), + }, + datetime.timedelta(days=0), + datetime.timedelta(days=2), + ), + ], + ids=["in_days", "in_weeks", "explit_date"], +) +async def test_add_event_date_ranges( + hass: HomeAssistant, + mock_token_read: None, + calendars_config: list[dict[str, Any]], + component_setup: ComponentSetup, + google_service: GoogleCalendarService, + mock_calendars_list: ApiResult, + test_calendar: dict[str, Any], + mock_insert_event: Mock, + date_fields: dict[str, Any], + start_timedelta: datetime.timedelta, + end_timedelta: datetime.timedelta, +) -> None: + """Test service call that adds an event with various time ranges.""" + + assert await component_setup() + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_EVENT, + { + "calendar_id": CALENDAR_ID, + "summary": "Summary", + "description": "Description", + **date_fields, + }, + blocking=True, + ) + mock_insert_event.assert_called() + + now = datetime.datetime.now() + start_date = now + start_timedelta + end_date = now + end_timedelta + + assert mock_insert_event.mock_calls[0] == call( + calendarId=CALENDAR_ID, + body={ + "summary": "Summary", + "description": "Description", + "start": {"date": start_date.date().isoformat()}, + "end": {"date": end_date.date().isoformat()}, + }, + )