Address Google mail late review (#86847)

This commit is contained in:
Robert Hillis 2023-01-30 08:18:56 -05:00 committed by GitHub
parent 3b5fd4bd06
commit 032a37b121
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 88 additions and 33 deletions

View File

@ -13,14 +13,22 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session, OAuth2Session,
async_get_config_entry_implementation, async_get_config_entry_implementation,
) )
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
from .const import DATA_AUTH, DOMAIN from .const import DATA_AUTH, DATA_HASS_CONFIG, DOMAIN
from .services import async_setup_services from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google Mail platform."""
hass.data.setdefault(DOMAIN, {})[DATA_HASS_CONFIG] = config
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Mail from a config entry.""" """Set up Google Mail from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry) implementation = await async_get_config_entry_implementation(hass, entry)
@ -36,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except ClientError as err: except ClientError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth hass.data[DOMAIN][entry.entry_id] = auth
hass.async_create_task( hass.async_create_task(
discovery.async_load_platform( discovery.async_load_platform(
@ -44,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Platform.NOTIFY, Platform.NOTIFY,
DOMAIN, DOMAIN,
{DATA_AUTH: auth, CONF_NAME: entry.title}, {DATA_AUTH: auth, CONF_NAME: entry.title},
{}, hass.data[DOMAIN][DATA_HASS_CONFIG],
) )
) )

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any, cast
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
@ -57,23 +57,29 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Create an entry for the flow, or update existing entry.""" """Create an entry for the flow, or update existing entry."""
if self.reauth_entry:
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) def _get_profile() -> str:
def _get_profile() -> dict[str, Any]:
"""Get profile from inside the executor.""" """Get profile from inside the executor."""
users = build( # pylint: disable=no-member users = build( # pylint: disable=no-member
"gmail", "v1", credentials=credentials "gmail", "v1", credentials=credentials
).users() ).users()
return users.getProfile(userId="me").execute() return users.getProfile(userId="me").execute()["emailAddress"]
email = (await self.hass.async_add_executor_job(_get_profile))["emailAddress"] credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
email = await self.hass.async_add_executor_job(_get_profile)
await self.async_set_unique_id(email) if not self.reauth_entry:
self._abort_if_unique_id_configured() await self.async_set_unique_id(email)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=email, data=data) return self.async_create_entry(title=email, data=data)
if self.reauth_entry.unique_id == email:
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_abort(
reason="wrong_account",
description_placeholders={"email": cast(str, self.reauth_entry.unique_id)},
)

View File

@ -16,6 +16,7 @@ ATTR_START = "start"
ATTR_TITLE = "title" ATTR_TITLE = "title"
DATA_AUTH = "auth" DATA_AUTH = "auth"
DATA_HASS_CONFIG = "hass_config"
DEFAULT_ACCESS = [ DEFAULT_ACCESS = [
"https://www.googleapis.com/auth/gmail.compose", "https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.settings.basic", "https://www.googleapis.com/auth/gmail.settings.basic",

View File

@ -1,6 +1,7 @@
"""Entity representing a Google Mail account.""" """Entity representing a Google Mail account."""
from __future__ import annotations from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
@ -24,6 +25,7 @@ class GoogleMailEntity(Entity):
f"{auth.oauth_session.config_entry.entry_id}_{description.key}" f"{auth.oauth_session.config_entry.entry_id}_{description.key}"
) )
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)}, identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=auth.oauth_session.config_entry.unique_id, name=auth.oauth_session.config_entry.unique_id,

View File

@ -7,5 +7,5 @@
"requirements": ["google-api-python-client==2.71.0"], "requirements": ["google-api-python-client==2.71.0"],
"codeowners": ["@tkdrob"], "codeowners": ["@tkdrob"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"integration_type": "device" "integration_type": "service"
} }

View File

@ -3,10 +3,9 @@ from __future__ import annotations
import base64 import base64
from email.message import EmailMessage from email.message import EmailMessage
from typing import Any, cast from typing import Any
from googleapiclient.http import HttpRequest from googleapiclient.http import HttpRequest
import voluptuous as vol
from homeassistant.components.notify import ( from homeassistant.components.notify import (
ATTR_DATA, ATTR_DATA,
@ -27,9 +26,9 @@ async def async_get_service(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> GMailNotificationService: ) -> GMailNotificationService | None:
"""Get the notification service.""" """Get the notification service."""
return GMailNotificationService(cast(DiscoveryInfoType, discovery_info)) return GMailNotificationService(discovery_info) if discovery_info else None
class GMailNotificationService(BaseNotificationService): class GMailNotificationService(BaseNotificationService):
@ -61,6 +60,6 @@ class GMailNotificationService(BaseNotificationService):
msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body}) msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body})
else: else:
if not to_addrs: if not to_addrs:
raise vol.Invalid("recipient address required") raise ValueError("recipient address required")
msg = users.messages().send(userId=email["From"], body=body) msg = users.messages().send(userId=email["From"], body=body)
await self.hass.async_add_executor_job(msg.execute) await self.hass.async_add_executor_job(msg.execute)

View File

@ -21,7 +21,8 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_account": "Wrong account: Please authenticate with {email}."
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -12,7 +12,8 @@
"oauth_error": "Received invalid token data.", "oauth_error": "Received invalid token data.",
"reauth_successful": "Re-authentication was successful", "reauth_successful": "Re-authentication was successful",
"timeout_connect": "Timeout establishing connection", "timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error" "unknown": "Unexpected error",
"wrong_account": "Wrong account: Please authenticate with {email}."
}, },
"create_entry": { "create_entry": {
"default": "Successfully authenticated" "default": "Successfully authenticated"

View File

@ -2007,7 +2007,7 @@
"name": "Google Domains" "name": "Google Domains"
}, },
"google_mail": { "google_mail": {
"integration_type": "device", "integration_type": "service",
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"name": "Google Mail" "name": "Google Mail"

View File

@ -115,6 +115,6 @@ async def mock_setup_integration(
), ),
): ):
assert await async_setup_component(hass, DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done() await hass.async_block_till_done()
return func return func

View File

@ -0,0 +1,6 @@
{
"emailAddress": "example2@gmail.com",
"messagesTotal": 35308,
"threadsTotal": 33901,
"historyId": "4178212"
}

View File

@ -2,6 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from httplib2 import Response from httplib2 import Response
import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.google_mail.const import DOMAIN from homeassistant.components.google_mail.const import DOMAIN
@ -68,14 +69,36 @@ async def test_full_flow(
) )
@pytest.mark.parametrize(
"fixture,abort_reason,placeholders,calls,access_token",
[
("get_profile", "reauth_successful", None, 1, "updated-access-token"),
(
"get_profile_2",
"wrong_account",
{"email": "example@gmail.com"},
0,
"mock-access-token",
),
],
)
async def test_reauth( async def test_reauth(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth, hass_client_no_auth,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
current_request_with_host, current_request_with_host,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
fixture: str,
abort_reason: str,
placeholders: dict[str, str],
calls: int,
access_token: str,
) -> None: ) -> None:
"""Test the reauthentication case updates the existing config entry.""" """Test the re-authentication case updates the correct config entry.
Make sure we abort if the user selects the
wrong account on the consent screen.
"""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass) config_entry.async_start_reauth(hass)
@ -118,19 +141,26 @@ async def test_reauth(
with patch( with patch(
"homeassistant.components.google_mail.async_setup_entry", return_value=True "homeassistant.components.google_mail.async_setup_entry", return_value=True
) as mock_setup: ) as mock_setup, patch(
"httplib2.Http.request",
return_value=(
Response({}),
bytes(load_fixture(f"google_mail/{fixture}.json"), encoding="UTF-8"),
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert result.get("type") == "abort" assert result.get("type") == "abort"
assert result.get("reason") == "reauth_successful" assert result["reason"] == abort_reason
assert result["description_placeholders"] == placeholders
assert len(mock_setup.mock_calls) == calls
assert config_entry.unique_id == TITLE assert config_entry.unique_id == TITLE
assert "token" in config_entry.data assert "token" in config_entry.data
# Verify access token is refreshed # Verify access token is refreshed
assert config_entry.data["token"].get("access_token") == "updated-access-token" assert config_entry.data["token"].get("access_token") == access_token
assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token"

View File

@ -29,7 +29,7 @@ async def test_setup_success(
await hass.config_entries.async_unload(entries[0].entry_id) await hass.config_entries.async_unload(entries[0].entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert not len(hass.services.async_services().get(DOMAIN, {})) assert not hass.services.async_services().get(DOMAIN)
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
@ -125,6 +125,7 @@ async def test_device_info(
entry = hass.config_entries.async_entries(DOMAIN)[0] entry = hass.config_entries.async_entries(DOMAIN)[0]
device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) device = device_registry.async_get_device({(DOMAIN, entry.entry_id)})
assert device.entry_type is dr.DeviceEntryType.SERVICE
assert device.identifiers == {(DOMAIN, entry.entry_id)} assert device.identifiers == {(DOMAIN, entry.entry_id)}
assert device.manufacturer == "Google, Inc." assert device.manufacturer == "Google, Inc."
assert device.name == "example@gmail.com" assert device.name == "example@gmail.com"

View File

@ -52,7 +52,7 @@ async def test_notify_voluptuous_error(
"""Test voluptuous error thrown when drafting email.""" """Test voluptuous error thrown when drafting email."""
await setup_integration() await setup_integration()
with pytest.raises(Invalid) as ex: with pytest.raises(ValueError) as ex:
await hass.services.async_call( await hass.services.async_call(
NOTIFY_DOMAIN, NOTIFY_DOMAIN,
"example_gmail_com", "example_gmail_com",