Add Google Photos reauth support (#124933)

* Add Google Photos reauth support

* Update tests/components/google_photos/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Allen Porter 2024-08-30 08:31:24 -07:00 committed by GitHub
parent 28c24e5fef
commit cb742a677c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 230 additions and 67 deletions

View File

@ -6,7 +6,7 @@ from aiohttp import ClientError, ClientResponseError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from . import api from . import api
@ -32,7 +32,13 @@ async def async_setup_entry(
auth = api.AsyncConfigEntryAuth(hass, session) auth = api.AsyncConfigEntryAuth(hass, session)
try: try:
await auth.async_get_access_token() await auth.async_get_access_token()
except (ClientResponseError, ClientError) as err: except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
entry.runtime_data = auth entry.runtime_data = auth
return True return True

View File

@ -1,5 +1,6 @@
"""Config flow for Google Photos.""" """Config flow for Google Photos."""
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@ -7,7 +8,7 @@ from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from . import api from . import GooglePhotosConfigEntry, api
from .const import DOMAIN, OAUTH2_SCOPES from .const import DOMAIN, OAUTH2_SCOPES
from .exceptions import GooglePhotosApiError from .exceptions import GooglePhotosApiError
@ -19,6 +20,8 @@ class OAuth2FlowHandler(
DOMAIN = DOMAIN DOMAIN = DOMAIN
reauth_entry: GooglePhotosConfigEntry | None = None
@property @property
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
"""Return logger.""" """Return logger."""
@ -49,6 +52,31 @@ class OAuth2FlowHandler(
self.logger.exception("Unknown error occurred") self.logger.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
user_id = user_resource_info["id"] user_id = user_resource_info["id"]
if self.reauth_entry:
if self.reauth_entry.unique_id == user_id:
return self.async_update_reload_and_abort(
self.reauth_entry, unique_id=user_id, data=data
)
return self.async_abort(reason="wrong_account")
await self.async_set_unique_id(user_id) await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_resource_info["name"], data=data) return self.async_create_entry(title=user_resource_info["name"], data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@ -18,16 +18,18 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_array_fixture from tests.common import MockConfigEntry, load_json_array_fixture
USER_IDENTIFIER = "user-identifier-1" USER_IDENTIFIER = "user-identifier-1"
CONFIG_ENTRY_ID = "user-identifier-1"
CLIENT_ID = "1234" CLIENT_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
FAKE_ACCESS_TOKEN = "some-access-token" FAKE_ACCESS_TOKEN = "some-access-token"
FAKE_REFRESH_TOKEN = "some-refresh-token" FAKE_REFRESH_TOKEN = "some-refresh-token"
EXPIRES_IN = 3600
@pytest.fixture(name="expires_at") @pytest.fixture(name="expires_at")
def mock_expires_at() -> int: def mock_expires_at() -> int:
"""Fixture to set the oauth token expiration time.""" """Fixture to set the oauth token expiration time."""
return time.time() + 3600 return time.time() + EXPIRES_IN
@pytest.fixture(name="token_entry") @pytest.fixture(name="token_entry")
@ -37,17 +39,26 @@ def mock_token_entry(expires_at: int) -> dict[str, Any]:
"access_token": FAKE_ACCESS_TOKEN, "access_token": FAKE_ACCESS_TOKEN,
"refresh_token": FAKE_REFRESH_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN,
"scope": " ".join(OAUTH2_SCOPES), "scope": " ".join(OAUTH2_SCOPES),
"token_type": "Bearer", "type": "Bearer",
"expires_at": expires_at, "expires_at": expires_at,
"expires_in": EXPIRES_IN,
} }
@pytest.fixture(name="config_entry_id")
def mock_config_entry_id() -> str | None:
"""Provide a json fixture file to load for list media item api responses."""
return CONFIG_ENTRY_ID
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: def mock_config_entry(
config_entry_id: str, token_entry: dict[str, Any]
) -> MockConfigEntry:
"""Fixture for a config entry.""" """Fixture for a config entry."""
return MockConfigEntry( return MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id="config-entry-id-123", unique_id=config_entry_id,
data={ data={
"auth_implementation": DOMAIN, "auth_implementation": DOMAIN,
"token": token_entry, "token": token_entry,
@ -73,12 +84,20 @@ def mock_fixture_name() -> str | None:
return None return None
@pytest.fixture(name="user_identifier")
def mock_user_identifier() -> str | None:
"""Provide a json fixture file to load for list media item api responses."""
return USER_IDENTIFIER
@pytest.fixture(name="setup_api") @pytest.fixture(name="setup_api")
def mock_setup_api(fixture_name: str) -> Generator[Mock, None, None]: def mock_setup_api(
fixture_name: str, user_identifier: str
) -> Generator[Mock, None, None]:
"""Set up fake Google Photos API responses from fixtures.""" """Set up fake Google Photos API responses from fixtures."""
with patch("homeassistant.components.google_photos.api.build") as mock: with patch("homeassistant.components.google_photos.api.build") as mock:
mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { mock.return_value.userinfo.return_value.get.return_value.execute.return_value = {
"id": USER_IDENTIFIER, "id": user_identifier,
"name": "Test Name", "name": "Test Name",
} }

View File

@ -1,5 +1,7 @@
"""Test the Google Photos config flow.""" """Test the Google Photos config flow."""
from collections.abc import Generator
from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
@ -16,9 +18,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import USER_IDENTIFIER from .conftest import EXPIRES_IN, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, USER_IDENTIFIER
from tests.common import load_fixture from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -26,12 +28,44 @@ CLIENT_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"
@pytest.fixture(name="mock_setup")
def mock_setup_entry() -> Generator[Mock, None, None]:
"""Fixture to mock out integration setup."""
with patch(
"homeassistant.components.google_photos.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture(name="updated_token_entry", autouse=True)
def mock_updated_token_entry() -> dict[str, Any]:
"""Fixture to provide any test specific overrides to token data from the oauth token endpoint."""
return {}
@pytest.fixture(name="mock_oauth_token_request", autouse=True)
def mock_token_request(
aioclient_mock: AiohttpClientMocker,
token_entry: dict[str, any],
updated_token_entry: dict[str, Any],
) -> None:
"""Fixture to provide a fake response from the oauth token endpoint."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
json={
**token_entry,
**updated_token_entry,
},
)
@pytest.mark.usefixtures("current_request_with_host", "setup_api") @pytest.mark.usefixtures("current_request_with_host", "setup_api")
@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"])
async def test_full_flow( async def test_full_flow(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker, mock_setup: Mock,
) -> None: ) -> None:
"""Check full flow.""" """Check full flow."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -59,20 +93,7 @@ async def test_full_flow(
assert resp.status == 200 assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post( result = await hass.config_entries.flow.async_configure(result["flow_id"])
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_photos.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
config_entry = result["result"] config_entry = result["result"]
assert config_entry.unique_id == USER_IDENTIFIER assert config_entry.unique_id == USER_IDENTIFIER
@ -84,10 +105,14 @@ async def test_full_flow(
assert config_entry_data == { assert config_entry_data == {
"auth_implementation": DOMAIN, "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": "mock-access-token", "access_token": FAKE_ACCESS_TOKEN,
"expires_in": 60, "expires_in": EXPIRES_IN,
"refresh_token": "mock-refresh-token", "refresh_token": FAKE_REFRESH_TOKEN,
"type": "Bearer", "type": "Bearer",
"scope": (
"https://www.googleapis.com/auth/photoslibrary.readonly"
" https://www.googleapis.com/auth/userinfo.profile"
),
}, },
} }
assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@ -101,7 +126,6 @@ async def test_full_flow(
async def test_api_not_enabled( async def test_api_not_enabled(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
setup_api: Mock, setup_api: Mock,
) -> None: ) -> None:
"""Check flow aborts if api is not enabled.""" """Check flow aborts if api is not enabled."""
@ -130,16 +154,6 @@ async def test_api_not_enabled(
assert resp.status == 200 assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
setup_api.return_value.mediaItems.return_value.list = Mock() setup_api.return_value.mediaItems.return_value.list = Mock()
setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError(
Response({"status": "403"}), Response({"status": "403"}),
@ -158,7 +172,6 @@ async def test_api_not_enabled(
async def test_general_exception( async def test_general_exception(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Check flow aborts if exception happens.""" """Check flow aborts if exception happens."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -185,16 +198,6 @@ async def test_general_exception(
assert resp.status == 200 assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8" assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch( with patch(
"homeassistant.components.google_photos.api.build", "homeassistant.components.google_photos.api.build",
side_effect=Exception, side_effect=Exception,
@ -203,3 +206,108 @@ async def test_general_exception(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown" assert result["reason"] == "unknown"
@pytest.mark.usefixtures("current_request_with_host", "setup_api", "setup_integration")
@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"])
@pytest.mark.parametrize(
"updated_token_entry",
[
{
"access_token": "updated-access-token",
}
],
)
@pytest.mark.parametrize(
(
"user_identifier",
"abort_reason",
"resulting_access_token",
"expected_setup_calls",
),
[
(
USER_IDENTIFIER,
"reauth_successful",
"updated-access-token",
1,
),
(
"345",
"wrong_account",
FAKE_ACCESS_TOKEN,
0,
),
],
)
@pytest.mark.usefixtures("current_request_with_host")
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
config_entry: MockConfigEntry,
user_identifier: str,
abort_reason: str,
resulting_access_token: str,
mock_setup: Mock,
expected_setup_calls: int,
) -> None:
"""Test the re-authentication case updates the correct config entry."""
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"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&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"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == abort_reason
assert config_entry.unique_id == USER_IDENTIFIER
assert config_entry.title == "Account Name"
config_entry_data = dict(config_entry.data)
assert "token" in config_entry_data
assert "expires_at" in config_entry_data["token"]
del config_entry_data["token"]["expires_at"]
assert config_entry_data == {
"auth_implementation": DOMAIN,
"token": {
# Verify token is refreshed or not
"access_token": resulting_access_token,
"expires_in": EXPIRES_IN,
"refresh_token": FAKE_REFRESH_TOKEN,
"type": "Bearer",
"scope": (
"https://www.googleapis.com/auth/photoslibrary.readonly"
" https://www.googleapis.com/auth/userinfo.profile"
),
},
}
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == expected_setup_calls

View File

@ -80,9 +80,9 @@ async def test_expired_token_refresh_success(
[ [
( (
time.time() - 3600, time.time() - 3600,
http.HTTPStatus.NOT_FOUND, http.HTTPStatus.UNAUTHORIZED,
None, None,
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, # Reauth
), ),
( (
time.time() - 3600, time.time() - 3600,

View File

@ -17,6 +17,8 @@ from homeassistant.components.media_source import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .conftest import CONFIG_ENTRY_ID
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -52,8 +54,8 @@ async def test_no_config_entries(
( (
"list_mediaitems.json", "list_mediaitems.json",
[ [
("config-entry-id-123/p/id1", "example1.jpg"), (f"{CONFIG_ENTRY_ID}/p/id1", "example1.jpg"),
("config-entry-id-123/p/id2", "example2.mp4"), (f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"),
], ],
[ [
("http://img.example.com/id1=w2048", "image/jpeg"), ("http://img.example.com/id1=w2048", "image/jpeg"),
@ -73,22 +75,22 @@ async def test_recent_items(
assert browse.identifier is None assert browse.identifier is None
assert browse.title == "Google Photos" assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [ assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name") (CONFIG_ENTRY_ID, "Account Name")
] ]
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123") browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}")
assert browse.domain == DOMAIN assert browse.domain == DOMAIN
assert browse.identifier == "config-entry-id-123" assert browse.identifier == CONFIG_ENTRY_ID
assert browse.title == "Account Name" assert browse.title == "Account Name"
assert [(child.identifier, child.title) for child in browse.children] == [ assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123/a/recent", "Recent Photos") (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos")
] ]
browse = await async_browse_media( browse = await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent"
) )
assert browse.domain == DOMAIN assert browse.domain == DOMAIN
assert browse.identifier == "config-entry-id-123" assert browse.identifier == CONFIG_ENTRY_ID
assert browse.title == "Account Name" assert browse.title == "Account Name"
assert [ assert [
(child.identifier, child.title) for child in browse.children (child.identifier, child.title) for child in browse.children
@ -121,12 +123,12 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None:
assert browse.identifier is None assert browse.identifier is None
assert browse.title == "Google Photos" assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [ assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name") (CONFIG_ENTRY_ID, "Account Name")
] ]
with pytest.raises(BrowseError, match="Unsupported album"): with pytest.raises(BrowseError, match="Unsupported album"):
await async_browse_media( await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/invalid-album-id" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id"
) )
@ -164,7 +166,7 @@ async def test_list_media_items_failure(
assert browse.identifier is None assert browse.identifier is None
assert browse.title == "Google Photos" assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [ assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name") (CONFIG_ENTRY_ID, "Account Name")
] ]
setup_api.return_value.mediaItems.return_value.list = Mock() setup_api.return_value.mediaItems.return_value.list = Mock()
@ -172,7 +174,7 @@ async def test_list_media_items_failure(
with pytest.raises(BrowseError, match="Error listing media items"): with pytest.raises(BrowseError, match="Error listing media items"):
await async_browse_media( await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent"
) )
@ -191,9 +193,9 @@ async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None:
assert browse.identifier is None assert browse.identifier is None
assert browse.title == "Google Photos" assert browse.title == "Google Photos"
assert [(child.identifier, child.title) for child in browse.children] == [ assert [(child.identifier, child.title) for child in browse.children] == [
("config-entry-id-123", "Account Name") (CONFIG_ENTRY_ID, "Account Name")
] ]
with pytest.raises(BrowseError, match="Error listing media items"): with pytest.raises(BrowseError, match="Error listing media items"):
await async_browse_media( await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent"
) )