diff --git a/CODEOWNERS b/CODEOWNERS index d5d3e597f70..4c3ebac5bf1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -421,6 +421,7 @@ build.json @home-assistant/supervisor /homeassistant/components/google_assistant/ @home-assistant/cloud /tests/components/google_assistant/ @home-assistant/cloud /homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_sheets/ @tkdrob /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py new file mode 100644 index 00000000000..a4c10da7f23 --- /dev/null +++ b/homeassistant/components/google_sheets/__init__.py @@ -0,0 +1,113 @@ +"""Support for Google Sheets.""" +from __future__ import annotations + +from datetime import datetime +from typing import cast + +import aiohttp +from google.auth.exceptions import RefreshError +from google.oauth2.credentials import Credentials +from gspread import Client +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ConfigEntrySelector + +from .const import DATA_CONFIG_ENTRY, DEFAULT_ACCESS, DOMAIN + +DATA = "data" +WORKSHEET = "worksheet" + +SERVICE_APPEND_SHEET = "append_sheet" + +SHEET_SERVICE_SCHEMA = vol.All( + { + vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Optional(WORKSHEET): cv.string, + vol.Required(DATA): dict, + }, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Sheets from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + + if not async_entry_has_scopes(hass, entry): + raise ConfigEntryAuthFailed("Required scopes are not present, reauth required") + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session + + await async_setup_service(hass) + + return True + + +def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Verify that the config entry desired scope is present in the oauth token.""" + return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ") + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + return True + + +async def async_setup_service(hass: HomeAssistant) -> None: + """Add the services for Google Sheets.""" + + def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: + """Run append in the executor.""" + service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + try: + sheet = service.open_by_key(entry.unique_id) + except RefreshError as ex: + entry.async_start_reauth(hass) + raise ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) + row_data = {"created": str(datetime.now())} | call.data[DATA] + columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) + row = [row_data.get(column, "") for column in columns] + for key, value in row_data.items(): + if key not in columns: + columns.append(key) + worksheet.update_cell(1, len(columns), key) + row.append(value) + worksheet.append_row(row) + + async def append_to_sheet(call: ServiceCall) -> None: + """Append new line of data to a Google Sheets document.""" + + entry = cast( + ConfigEntry, + hass.config_entries.async_get_entry(call.data[DATA_CONFIG_ENTRY]), + ) + session: OAuth2Session = hass.data[DOMAIN][entry.entry_id] + await session.async_ensure_token_valid() + await hass.async_add_executor_job(_append_to_sheet, call, entry) + + hass.services.async_register( + DOMAIN, + SERVICE_APPEND_SHEET, + append_to_sheet, + schema=SHEET_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/google_sheets/application_credentials.py b/homeassistant/components/google_sheets/application_credentials.py new file mode 100644 index 00000000000..c54356b659e --- /dev/null +++ b/homeassistant/components/google_sheets/application_credentials.py @@ -0,0 +1,27 @@ +"""application_credentials platform for Google Sheets.""" + +import oauth2client + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +AUTHORIZATION_SERVER = AuthorizationServer( + oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI +) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + oauth2client.GOOGLE_AUTH_URI, + oauth2client.GOOGLE_TOKEN_URI, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_sheets/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_sheets/config_flow.py b/homeassistant/components/google_sheets/config_flow.py new file mode 100644 index 00000000000..d19a5b5c3fa --- /dev/null +++ b/homeassistant/components/google_sheets/config_flow.py @@ -0,0 +1,102 @@ +"""Config flow for Google Sheets integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from gspread import Client, GSpreadException + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Sheets OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": DEFAULT_ACCESS, + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + 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() + + def _async_reauth_entry(self) -> ConfigEntry | None: + """Return existing entry for reauth.""" + if self.source != SOURCE_REAUTH or not ( + entry_id := self.context.get("entry_id") + ): + return None + return next( + ( + entry + for entry in self._async_current_entries() + if entry.entry_id == entry_id + ), + None, + ) + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + + if entry := self._async_reauth_entry(): + _LOGGER.debug("service.open_by_key") + try: + await self.hass.async_add_executor_job( + service.open_by_key, + entry.unique_id, + ) + except GSpreadException as err: + _LOGGER.error( + "Could not find spreadsheet '%s': %s", entry.unique_id, str(err) + ) + return self.async_abort(reason="open_spreadsheet_failure") + + 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") + + try: + doc = await self.hass.async_add_executor_job( + service.create, "Home Assistant" + ) + except GSpreadException as err: + _LOGGER.error("Error creating spreadsheet: %s", str(err)) + return self.async_abort(reason="create_spreadsheet_failure") + + await self.async_set_unique_id(doc.id) + return self.async_create_entry( + title=DEFAULT_NAME, data=data, description_placeholders={"url": doc.url} + ) diff --git a/homeassistant/components/google_sheets/const.py b/homeassistant/components/google_sheets/const.py new file mode 100644 index 00000000000..f8f065972f9 --- /dev/null +++ b/homeassistant/components/google_sheets/const.py @@ -0,0 +1,10 @@ +"""Constants for Google Sheets integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN = "google_sheets" + +DATA_CONFIG_ENTRY: Final = "config_entry" +DEFAULT_NAME = "Google Sheets" +DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file" diff --git a/homeassistant/components/google_sheets/manifest.json b/homeassistant/components/google_sheets/manifest.json new file mode 100644 index 00000000000..c8d86210b42 --- /dev/null +++ b/homeassistant/components/google_sheets/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_sheets", + "name": "Google Sheets", + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_sheets/", + "requirements": ["gspread==5.5.0"], + "codeowners": ["@tkdrob"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/google_sheets/services.yaml b/homeassistant/components/google_sheets/services.yaml new file mode 100644 index 00000000000..7524ba50fb5 --- /dev/null +++ b/homeassistant/components/google_sheets/services.yaml @@ -0,0 +1,24 @@ +append_sheet: + name: Append to Sheet + description: Append data to a worksheet in Google Sheets. + fields: + config_entry: + name: Sheet + description: The sheet to add data to + required: true + selector: + config_entry: + integration: google_sheets + worksheet: + name: Worksheet + description: Name of the worksheet. Defaults to the first one in the document. + example: "Sheet1" + selector: + text: + data: + name: Data + description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column. + required: true + example: '{"hello": world, "cool": True, "count": 5}' + selector: + object: diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json new file mode 100644 index 00000000000..858f6856954 --- /dev/null +++ b/homeassistant/components/google_sheets/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "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%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details" + }, + "create_entry": { + "default": "Successfully authenticated and spreadsheet created at: {url}" + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + } +} diff --git a/homeassistant/components/google_sheets/translations/en.json b/homeassistant/components/google_sheets/translations/en.json new file mode 100644 index 00000000000..c7348a0fa40 --- /dev/null +++ b/homeassistant/components/google_sheets/translations/en.json @@ -0,0 +1,31 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "abort": { + "already_configured": "Account is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details", + "invalid_access_token": "Invalid access token", + "missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth_error": "Received invalid token data.", + "open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details", + "reauth_successful": "Re-authentication was successful", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "create_entry": { + "default": "Successfully authenticated and spreadsheet created at: {url}" + }, + "step": { + "auth": { + "title": "Link Google Account" + }, + "pick_implementation": { + "title": "Pick Authentication Method" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 4673cf2378d..fb2b04989f7 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -8,6 +8,7 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "geocaching", "google", + "google_sheets", "home_connect", "lametric", "lyric", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 60ac2e8d511..1aa49e279db 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -142,6 +142,7 @@ FLOWS = { "gogogate2", "goodwe", "google", + "google_sheets", "google_travel_time", "govee_ble", "gpslogger", diff --git a/requirements_all.txt b/requirements_all.txt index 3812c4fed8c..72c3c86d9e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,6 +798,9 @@ gridnet==4.0.0 # homeassistant.components.growatt_server growattServer==1.2.2 +# homeassistant.components.google_sheets +gspread==5.5.0 + # homeassistant.components.gstreamer gstreamer-player==1.1.2 diff --git a/tests/components/google_sheets/test_config_flow.py b/tests/components/google_sheets/test_config_flow.py new file mode 100644 index 00000000000..3fcd2f99ed0 --- /dev/null +++ b/tests/components/google_sheets/test_config_flow.py @@ -0,0 +1,314 @@ +"""Test the Google Sheets config flow.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +from gspread import GSpreadException +import oauth2client +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_sheets.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +SHEET_ID = "google-sheet-id" +TITLE = "Google Sheets" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(autouse=True) +async def mock_client() -> Generator[Mock, None, None]: + """Fixture to setup a fake spreadsheet client library.""" + with patch( + "homeassistant.components.google_sheets.config_flow.Client" + ) as mock_client: + yield mock_client + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_sheets", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare fake client library response when creating the sheet + mock_create = Mock() + mock_create.return_value.id = SHEET_ID + mock_client.return_value.create = mock_create + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_sheets.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(mock_client.mock_calls) == 2 + + assert result.get("type") == "create_entry" + assert result.get("title") == TITLE + assert "result" in result + assert result.get("result").unique_id == SHEET_ID + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +async def test_create_sheet_error( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test case where creating the spreadsheet fails.""" + result = await hass.config_entries.flow.async_init( + "google_sheets", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare fake exception creating the spreadsheet + mock_create = Mock() + mock_create.side_effect = GSpreadException() + mock_client.return_value.create = mock_create + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "create_spreadsheet_failure" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test the reauthentication case updates the existing config entry.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SHEET_ID, + data={ + "token": { + "access_token": "mock-access-token", + }, + }, + ) + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Config flow will lookup existing key to make sure it still exists + mock_open = Mock() + mock_open.return_value.id = SHEET_ID + mock_client.return_value.open_by_key = mock_open + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_sheets.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result.get("type") == "abort" + assert result.get("reason") == "reauth_successful" + + assert config_entry.unique_id == SHEET_ID + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == "updated-access-token" + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +async def test_reauth_abort( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, + mock_client, +) -> None: + """Test failure case during reauth.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SHEET_ID, + data={ + "token": { + "access_token": "mock-access-token", + }, + }, + ) + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Simulate failure looking up existing spreadsheet + mock_open = Mock() + mock_open.return_value.id = SHEET_ID + mock_open.side_effect = GSpreadException() + mock_client.return_value.open_by_key = mock_open + + aioclient_mock.post( + oauth2client.GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == "abort" + assert result.get("reason") == "open_spreadsheet_failure" diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py new file mode 100644 index 00000000000..d060e01bac2 --- /dev/null +++ b/tests/components/google_sheets/test_init.py @@ -0,0 +1,214 @@ +"""Tests for Google Sheets.""" + +from collections.abc import Awaitable, Callable, Generator +import http +import time +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_sheets import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +TEST_SHEET_ID = "google-sheet-it" + +ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return ["https://www.googleapis.com/auth/drive.file"] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SHEET_ID, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Generator[ComponentSetup, None, None]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential("client-id", "client-secret"), + DOMAIN, + ) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func + + # Verify clean unload + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert entries[0].state is ConfigEntryState.NOT_LOADED + + +async def test_setup_success( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "scopes", + [ + [], + [ + "https://www.googleapis.com/auth/drive.file+plus+extra" + ], # Required scope is a prefix + ["https://www.googleapis.com/auth/drive.readonly"], + ], + ids=["no_scope", "required_scope_prefix", "other_scope"], +) +async def test_missing_required_scopes_requires_reauth( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test that reauth is invoked when required scopes are not present.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + scopes: list[str], + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + "expires_at,status,expected_state", + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + scopes: list[str], + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_append_sheet( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + with patch("homeassistant.components.google_sheets.Client") as mock_client: + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + assert len(mock_client.mock_calls) == 8