diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index ab1ee4a63a4..643ad0b41ad 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -6,7 +6,7 @@ from aiohttp import ClientError, ClientResponseError from homeassistant.config_entries import ConfigEntry 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 . import api @@ -32,7 +32,13 @@ async def async_setup_entry( auth = api.AsyncConfigEntryAuth(hass, session) try: 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 entry.runtime_data = auth return True diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py index 9bc4b35b6b4..93f0347e32f 100644 --- a/homeassistant/components/google_photos/config_flow.py +++ b/homeassistant/components/google_photos/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Google Photos.""" +from collections.abc import Mapping import logging 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.helpers import config_entry_oauth2_flow -from . import api +from . import GooglePhotosConfigEntry, api from .const import DOMAIN, OAUTH2_SCOPES from .exceptions import GooglePhotosApiError @@ -19,6 +20,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + reauth_entry: GooglePhotosConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -49,6 +52,31 @@ class OAuth2FlowHandler( self.logger.exception("Unknown error occurred") return self.async_abort(reason="unknown") 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) self._abort_if_unique_id_configured() 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() diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 874e55f0d33..84ed717895d 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -18,16 +18,18 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_array_fixture USER_IDENTIFIER = "user-identifier-1" +CONFIG_ENTRY_ID = "user-identifier-1" CLIENT_ID = "1234" CLIENT_SECRET = "5678" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +EXPIRES_IN = 3600 @pytest.fixture(name="expires_at") def mock_expires_at() -> int: """Fixture to set the oauth token expiration time.""" - return time.time() + 3600 + return time.time() + EXPIRES_IN @pytest.fixture(name="token_entry") @@ -37,17 +39,26 @@ def mock_token_entry(expires_at: int) -> dict[str, Any]: "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, "scope": " ".join(OAUTH2_SCOPES), - "token_type": "Bearer", + "type": "Bearer", "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") -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.""" return MockConfigEntry( domain=DOMAIN, - unique_id="config-entry-id-123", + unique_id=config_entry_id, data={ "auth_implementation": DOMAIN, "token": token_entry, @@ -73,12 +84,20 @@ def mock_fixture_name() -> str | 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") -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.""" with patch("homeassistant.components.google_photos.api.build") as mock: mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { - "id": USER_IDENTIFIER, + "id": user_identifier, "name": "Test Name", } diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index e9f2a68f2f5..4bd933a7eb8 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Google Photos config flow.""" +from collections.abc import Generator +from typing import Any from unittest.mock import Mock, patch from googleapiclient.errors import HttpError @@ -16,9 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType 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.typing import ClientSessionGenerator @@ -26,12 +28,44 @@ CLIENT_ID = "1234" 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.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, + mock_setup: Mock, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -59,20 +93,7 @@ async def test_full_flow( assert resp.status == 200 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( - "homeassistant.components.google_photos.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == USER_IDENTIFIER @@ -84,10 +105,14 @@ async def test_full_flow( assert config_entry_data == { "auth_implementation": DOMAIN, "token": { - "access_token": "mock-access-token", - "expires_in": 60, - "refresh_token": "mock-refresh-token", + "access_token": FAKE_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 @@ -101,7 +126,6 @@ async def test_full_flow( async def test_api_not_enabled( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, setup_api: Mock, ) -> None: """Check flow aborts if api is not enabled.""" @@ -130,16 +154,6 @@ async def test_api_not_enabled( assert resp.status == 200 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.return_value.execute.side_effect = HttpError( Response({"status": "403"}), @@ -158,7 +172,6 @@ async def test_api_not_enabled( async def test_general_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, ) -> None: """Check flow aborts if exception happens.""" result = await hass.config_entries.flow.async_init( @@ -185,16 +198,6 @@ async def test_general_exception( assert resp.status == 200 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( "homeassistant.components.google_photos.api.build", side_effect=Exception, @@ -203,3 +206,108 @@ async def test_general_exception( assert result["type"] is FlowResultType.ABORT 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 diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py index a2f835c8611..ea236cfc712 100644 --- a/tests/components/google_photos/test_init.py +++ b/tests/components/google_photos/test_init.py @@ -80,9 +80,9 @@ async def test_expired_token_refresh_success( [ ( time.time() - 3600, - http.HTTPStatus.NOT_FOUND, + http.HTTPStatus.UNAUTHORIZED, None, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_ERROR, # Reauth ), ( time.time() - 3600, diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 31c84f4811c..b24b37c10e6 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -17,6 +17,8 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import CONFIG_ENTRY_ID + from tests.common import MockConfigEntry @@ -52,8 +54,8 @@ async def test_no_config_entries( ( "list_mediaitems.json", [ - ("config-entry-id-123/p/id1", "example1.jpg"), - ("config-entry-id-123/p/id2", "example2.mp4"), + (f"{CONFIG_ENTRY_ID}/p/id1", "example1.jpg"), + (f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"), ], [ ("http://img.example.com/id1=w2048", "image/jpeg"), @@ -73,22 +75,22 @@ async def test_recent_items( assert browse.identifier is None assert browse.title == "Google Photos" 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.identifier == "config-entry-id-123" + assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" 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( - 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.identifier == "config-entry-id-123" + assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [ (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.title == "Google Photos" 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"): 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.title == "Google Photos" 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() @@ -172,7 +174,7 @@ async def test_list_media_items_failure( with pytest.raises(BrowseError, match="Error listing media items"): 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.title == "Google Photos" 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"): 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" )