From ef84a8869e6e5d1772688f9a02f2c91319fe10ee Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 12:16:14 -0700 Subject: [PATCH] Add Google Photos service for uploading content (#124956) * Add Google Photos upload support * Fix format * Merge in scope/reauth changes * Address PR feedback * Fix blocking i/o in async --- .../components/google_photos/__init__.py | 4 + homeassistant/components/google_photos/api.py | 48 +++- .../components/google_photos/const.py | 9 +- .../components/google_photos/icons.json | 7 + .../components/google_photos/media_source.py | 13 +- .../components/google_photos/services.py | 116 ++++++++ .../components/google_photos/services.yaml | 11 + .../components/google_photos/strings.json | 37 +++ tests/components/google_photos/conftest.py | 10 +- .../google_photos/test_config_flow.py | 12 + .../google_photos/test_media_source.py | 20 +- .../components/google_photos/test_services.py | 256 ++++++++++++++++++ 12 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/google_photos/icons.json create mode 100644 homeassistant/components/google_photos/services.py create mode 100644 homeassistant/components/google_photos/services.yaml create mode 100644 tests/components/google_photos/test_services.py diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 643ad0b41ad..ee02c695f16 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN +from .services import async_register_services type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] @@ -41,6 +42,9 @@ async def async_setup_entry( except ClientError as err: raise ConfigEntryNotReady from err entry.runtime_data = auth + + async_register_services(hass) + return True diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index b387326148f..c5de03d7d21 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -5,6 +5,7 @@ from functools import partial import logging from typing import Any, cast +from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError @@ -12,7 +13,7 @@ from googleapiclient.http import BatchHttpRequest, HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .exceptions import GooglePhotosApiError @@ -25,6 +26,7 @@ GET_MEDIA_ITEM_FIELDS = ( "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" ) LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" +UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" class AuthBase(ABC): @@ -70,6 +72,40 @@ class AuthBase(ABC): ) return await self._execute(cmd) + async def upload_content(self, content: bytes, mime_type: str) -> str: + """Upload media content to the API and return an upload token.""" + token = await self.async_get_access_token() + session = aiohttp_client.async_get_clientsession(self._hass) + try: + result = await session.post( + UPLOAD_API, headers=_upload_headers(token, mime_type), data=content + ) + result.raise_for_status() + return await result.text() + except ClientError as err: + raise GooglePhotosApiError(f"Failed to upload content: {err}") from err + + async def create_media_items(self, upload_tokens: list[str]) -> list[str]: + """Create a batch of media items and return the ids.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().batchCreate( + body={ + "newMediaItems": [ + { + "simpleMediaItem": { + "uploadToken": upload_token, + } + for upload_token in upload_tokens + } + ] + } + ) + result = await self._execute(cmd) + return [ + media_item["mediaItem"]["id"] + for media_item in result["newMediaItemResults"] + ] + async def _get_photos_service(self) -> Resource: """Get current photos library API resource.""" token = await self.async_get_access_token() @@ -141,3 +177,13 @@ class AsyncConfigFlowAuth(AuthBase): async def async_get_access_token(self) -> str: """Return a valid access token.""" return self._token + + +def _upload_headers(token: str, mime_type: str) -> dict[str, Any]: + """Create the upload headers.""" + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/octet-stream", + "X-Goog-Upload-Content-Type": mime_type, + "X-Goog-Upload-Protocol": "raw", + } diff --git a/homeassistant/components/google_photos/const.py b/homeassistant/components/google_photos/const.py index 7752f817608..c629e6feb27 100644 --- a/homeassistant/components/google_photos/const.py +++ b/homeassistant/components/google_photos/const.py @@ -4,7 +4,14 @@ DOMAIN = "google_photos" OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" -OAUTH2_SCOPES = [ + +UPLOAD_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly" +READ_SCOPES = [ "https://www.googleapis.com/auth/photoslibrary.readonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", +] +OAUTH2_SCOPES = [ + *READ_SCOPES, + UPLOAD_SCOPE, "https://www.googleapis.com/auth/userinfo.profile", ] diff --git a/homeassistant/components/google_photos/icons.json b/homeassistant/components/google_photos/icons.json new file mode 100644 index 00000000000..5d51ed4370a --- /dev/null +++ b/homeassistant/components/google_photos/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "upload": { + "service": "mdi:cloud-upload" + } + } +} diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index cdb6b22a3ed..9b922ee3201 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -16,7 +16,7 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant from . import GooglePhotosConfigEntry -from .const import DOMAIN +from .const import DOMAIN, READ_SCOPES from .exceptions import GooglePhotosApiError _LOGGER = logging.getLogger(__name__) @@ -168,7 +168,7 @@ class GooglePhotosMediaSource(MediaSource): children_media_class=MediaClass.DIRECTORY, children=[ _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) - for entry in self.hass.config_entries.async_loaded_entries(DOMAIN) + for entry in self._async_config_entries() ], ) @@ -218,6 +218,15 @@ class GooglePhotosMediaSource(MediaSource): ] return source + def _async_config_entries(self) -> list[GooglePhotosConfigEntry]: + """Return all config entries that support photo library reads.""" + entries = [] + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + scopes = entry.data["token"]["scope"].split(" ") + if any(scope in scopes for scope in READ_SCOPES): + entries.append(entry) + return entries + def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry: """Return a config entry with the specified id.""" entry = self.hass.config_entries.async_entry_for_domain_unique_id( diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py new file mode 100644 index 00000000000..a895f333962 --- /dev/null +++ b/homeassistant/components/google_photos/services.py @@ -0,0 +1,116 @@ +"""Google Photos services.""" + +from __future__ import annotations + +import asyncio +import mimetypes +from pathlib import Path + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILENAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from . import api +from .const import DOMAIN, UPLOAD_SCOPE + +type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] + +__all__ = [ + "DOMAIN", +] + +CONF_CONFIG_ENTRY_ID = "config_entry_id" + +UPLOAD_SERVICE = "upload" +UPLOAD_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Read the mime type and contents from each filen.""" + results = [] + for filename in filenames: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": filename}, + ) + filename_path = Path(filename) + if not filename_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_does_not_exist", + translation_placeholders={"filename": filename}, + ) + mime_type, _ = mimetypes.guess_type(filename) + if mime_type is None or not (mime_type.startswith(("image", "video"))): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_is_not_image", + translation_placeholders={"filename": filename}, + ) + results.append((mime_type, filename_path.read_bytes())) + return results + + +def async_register_services(hass: HomeAssistant) -> None: + """Register Google Photos services.""" + + async def async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: GooglePhotosConfigEntry | None = ( + hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + scopes = config_entry.data["token"]["scope"].split(" ") + if UPLOAD_SCOPE not in scopes: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_upload_permission", + translation_placeholders={"target": DOMAIN}, + ) + + client_api = config_entry.runtime_data + upload_tasks = [] + file_results = await hass.async_add_executor_job( + _read_file_contents, hass, call.data[CONF_FILENAME] + ) + for mime_type, content in file_results: + upload_tasks.append(client_api.upload_content(content, mime_type)) + upload_tokens = await asyncio.gather(*upload_tasks) + media_ids = await client_api.create_media_items(upload_tokens) + if call.return_response: + return { + "media_items": [{"media_item_id": media_id for media_id in media_ids}] + } + return None + + if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE): + hass.services.async_register( + DOMAIN, + UPLOAD_SERVICE, + async_handle_upload, + schema=UPLOAD_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/google_photos/services.yaml b/homeassistant/components/google_photos/services.yaml new file mode 100644 index 00000000000..047305c0bca --- /dev/null +++ b/homeassistant/components/google_photos/services.yaml @@ -0,0 +1,11 @@ +upload: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: google_photos + filename: + required: false + selector: + object: diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index b44e04287b1..9e88429124e 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -26,5 +26,42 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "exceptions": { + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "filename_does_not_exist": { + "message": "`{filename}` does not exist" + }, + "filename_is_not_image": { + "message": "`{filename}` is not an image" + }, + "missing_upload_permission": { + "message": "Home Assistnt was not granted permission to upload to Google Photos" + } + }, + "services": { + "upload": { + "name": "Upload media", + "description": "Upload images or videos to Google Photos.", + "fields": { + "config_entry_id": { + "name": "Integration Id", + "description": "The Google Photos integration id." + }, + "filename": { + "name": "Filename", + "description": "Path to the image or video to upload.", + "example": "/config/www/image.jpg" + } + } + } } } diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 73e506658e6..2cdad5d4d10 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -32,13 +32,19 @@ def mock_expires_at() -> int: return time.time() + EXPIRES_IN +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set scopes used during the config entry.""" + return OAUTH2_SCOPES + + @pytest.fixture(name="token_entry") -def mock_token_entry(expires_at: int) -> dict[str, Any]: +def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]: """Fixture for OAuth 'token' data for a ConfigEntry.""" return { "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, - "scope": " ".join(OAUTH2_SCOPES), + "scope": " ".join(scopes), "type": "Bearer", "expires_at": expires_at, "expires_in": EXPIRES_IN, diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index 4bd933a7eb8..2564a8ed134 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -84,6 +84,8 @@ async def test_full_flow( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -111,6 +113,8 @@ async def test_full_flow( "type": "Bearer", "scope": ( "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), }, @@ -145,6 +149,8 @@ async def test_api_not_enabled( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -189,6 +195,8 @@ async def test_general_exception( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -274,6 +282,8 @@ async def test_reauth( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -305,6 +315,8 @@ async def test_reauth( "type": "Bearer", "scope": ( "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), }, diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index db57ab755c1..ff4993eb3df 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -7,7 +7,7 @@ from googleapiclient.errors import HttpError from httplib2 import Response import pytest -from homeassistant.components.google_photos.const import DOMAIN +from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE from homeassistant.components.media_source import ( URI_SCHEME, BrowseError, @@ -46,6 +46,24 @@ async def test_no_config_entries( assert not browse.children +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("scopes"), + [ + [UPLOAD_SCOPE], + ], +) +async def test_no_read_scopes( + hass: HomeAssistant, +) -> None: + """Test a media source with only write scopes configured so no media source exists.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert not browse.children + + @pytest.mark.usefixtures("setup_integration", "setup_api") @pytest.mark.parametrize( ("fixture_name", "expected_results", "expected_medias"), diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py new file mode 100644 index 00000000000..198de3295a9 --- /dev/null +++ b/tests/components/google_photos/test_services.py @@ -0,0 +1,256 @@ +"""Tests for Google Photos.""" + +import http +from unittest.mock import Mock, patch + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant.components.google_photos.api import UPLOAD_API +from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPES +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + assert hass.services.has_service(DOMAIN, "upload") + + aioclient_mock.post(UPLOAD_API, text="some-upload-token") + setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.return_value = { + "newMediaItemResults": [ + { + "status": { + "code": 200, + }, + "mediaItem": { + "id": "new-media-item-id-1", + }, + } + ] + } + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + response = await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_config_entry_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that does not exist.""" + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": "invalid-config-entry-id", + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_config_entry_not_loaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that is not loaded.""" + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.unique_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_path_is_not_allowed( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that is not allowed.""" + with ( + patch.object(hass.config, "is_allowed_path", return_value=False), + pytest.raises(HomeAssistantError, match="no access to path"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_filename_does_not_exist( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("pathlib.Path.exists", return_value=False), + pytest.raises(HomeAssistantError, match="does not exist"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_upload_content_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + + aioclient_mock.post(UPLOAD_API, status=http.HTTPStatus.SERVICE_UNAVAILABLE) + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Failed to upload content"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_fails_create( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + + aioclient_mock.post(UPLOAD_API, text="some-upload-token") + setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.side_effect = HttpError( + Response({"status": "403"}), b"" + ) + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises( + HomeAssistantError, match="Google Photos API responded with error" + ), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("scopes"), + [ + READ_SCOPES, + ], +) +async def test_upload_service_no_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content but the config entry is read-only.""" + + with pytest.raises(HomeAssistantError, match="not granted permission"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + )