From 78b032241526a9e0998d2935840e127a89e2ec6f Mon Sep 17 00:00:00 2001 From: Cristian Caruceru Date: Sat, 4 Jan 2025 20:00:23 +0100 Subject: [PATCH 1/5] oauth installation and state store for google cloud storage --- requirements/optional.txt | 1 + requirements/testing.txt | 1 + .../google_cloud_storage/__init__.py | 347 ++++++++++++++++++ .../google_cloud_storage/__init__.py | 120 ++++++ 4 files changed, 469 insertions(+) create mode 100644 slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py create mode 100644 slack_sdk/oauth/state_store/google_cloud_storage/__init__.py diff --git a/requirements/optional.txt b/requirements/optional.txt index f26a8a9a..c334fd06 100644 --- a/requirements/optional.txt +++ b/requirements/optional.txt @@ -6,6 +6,7 @@ aiodns>1.0 aiohttp>=3.7.3,<4 # used only under slack_sdk/*_store boto3<=2 +google-cloud-storage>=2.7.0,<3 # InstallationStore/OAuthStateStore # Since v3.20, we no longer support SQLAlchemy 1.3 or older. # If you need to use a legacy version, please add our v3.19.5 code to your project. diff --git a/requirements/testing.txt b/requirements/testing.txt index 17aef4d4..90e608b5 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -14,6 +14,7 @@ click==8.0.4 # black is affected by https://github.com/pallets/click/issues/222 psutil>=6.0.0,<7 # used only under slack_sdk/*_store boto3<=2 +google-cloud-storage>=2.7.0,<3 # For AWS tests moto>=4.0.13,<6 mypy<=1.13.0 diff --git a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py new file mode 100644 index 00000000..95a0a850 --- /dev/null +++ b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +"""Store Slack bot install data to a Google Cloud Storage bucket.""" + +import json +import logging +from logging import Logger +from typing import Optional + +from google.cloud.storage import Client # type: ignore[import-untyped] + +from slack_sdk.oauth.installation_store.async_installation_store import AsyncInstallationStore +from slack_sdk.oauth.installation_store.installation_store import InstallationStore +from slack_sdk.oauth.installation_store.models.bot import Bot +from slack_sdk.oauth.installation_store.models.installation import Installation + + +class GoogleCloudStorageInstallationStore(InstallationStore, AsyncInstallationStore): + """Store Slack user installation data to a Google Cloud Storage bucket. + + https://api.slack.com/authentication/oauth-v2 + + Attributes: + storage_client (Client): A Google Cloud Storage client to access the bucket + bucket_name (str): Bucket to store user installation data for current Slack app + client_id (str): Slack application client id + """ + + def __init__( + self, + *, + storage_client: Client, + bucket_name: str, + client_id: str, + logger: Logger = logging.getLogger(__name__), + ): + """Creates a new instance. + + Args: + storage_client (Client): A Google Cloud Storage client to access the bucket + bucket_name (str): Bucket to store user installation data for current Slack app + client_id (str): Slack application client id + logger (Logger): Custom logger for logging. Defaults to a new logger for this module. + """ + self.storage_client = storage_client + self.bucket = self.storage_client.bucket(bucket_name) + self.client_id = client_id + self._logger = logger + + @property + def logger(self) -> Logger: + """Gets the internal logger if it exists, otherwise creates a new one. + + Returns: + Logger: the logger + """ + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + async def async_save(self, installation: Installation): + """Save user's app authorization. + + Args: + installation (Installation): information about the user and the app usage authorization + """ + self.save(installation) + + def save(self, installation: Installation): + """Save user's app authorization. + + Args: + installation (Installation): information about the user and the app usage authorization + """ + # save bot data + self.save_bot(installation.to_bot()) + + # per workspace + entity = json.dumps(installation.__dict__) + self._save_entity( + data_type="installer", + entity=entity, + enterprise_id=installation.enterprise_id, + team_id=installation.team_id, + user_id=None, + ) + self.logger.debug("Uploaded %s to Google bucket as installer", entity) + + # per workspace per user + self._save_entity( + data_type="installer", + entity=entity, + enterprise_id=installation.enterprise_id, + team_id=installation.team_id, + user_id=installation.user_id or "none", + ) + self.logger.debug("Uploaded %s to Google bucket as installer-%s", entity, installation.user_id) + + async def async_save_bot(self, bot: Bot): + """Save bot user authorization. + + Args: + bot (Bot): data bout the bot + """ + self.save_bot(bot) + + def save_bot(self, bot: Bot): + """Save bot user authorization. + + Args: + bot (Bot): data bout the bot + """ + entity = json.dumps(bot.__dict__) + self._save_entity(data_type="bot", entity=entity, enterprise_id=bot.enterprise_id, team_id=bot.team_id, user_id=None) + self.logger.debug("Uploaded %s to Google bucket as bot", entity) + + def _save_entity( + self, data_type: str, entity: str, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] + ): + """Saves data to a GCS bucket. + + Args: + data_type (str): data type + entity (str): data payload + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID + """ + key = self._key(data_type=data_type, enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) + blob = self.bucket.blob(key) + blob.upload_from_string(entity) + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + """Check if a Slack bot user has been installed in a Slack workspace. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + is_enterprise_install (Optional[str]): True if the Slack app is installed across multiple workspaces in an + Enterprise Grid. Defaults to False. + + Returns: + Optional[Bot]: A Slack bot/app identifier object if found, else None + """ + return self.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=is_enterprise_install, + ) + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + """Check if a Slack bot user has been installed in a Slack workspace. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + is_enterprise_install (Optional[str]): True if the Slack app is installed across multiple workspaces in an + Enterprise Grid. Defaults to False + + Returns: + Optional[Bot]: A Slack bot/app identifier object if found, else None + """ + key = self._key( + data_type="bot", + enterprise_id=enterprise_id, + is_enterprise_install=is_enterprise_install, + team_id=team_id, + user_id=None, + ) + try: + blob = self.bucket.blob(key) + body = blob.download_as_text(encoding="utf-8") + self.logger.debug("Downloaded %s from Google bucket", body) + data = json.loads(body) + return Bot(**data) + except Exception as exc: + self.logger.warning( + "Failed to find bot installation data for enterprise: %s, team: %s: %s", enterprise_id, team_id, exc + ) + return None + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + """Check if a Slack user has installed the app. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID. Defaults to None. + is_enterprise_install (Optional[str]): True if the Slack app is installed across multiple workspaces in an + Enterprise Grid. Defaults to False + + Returns: + Optional[Installation]: A installation identifier object if found, else None + """ + return self.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=is_enterprise_install, + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + """Check if a Slack user has installed the app. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID. Defaults to None. + is_enterprise_install (Optional[str]): True if the Slack app is installed across multiple workspaces in an + Enterprise Grid. Defaults to False + + Returns: + Optional[Installation]: A installation identifier object if found, else None + """ + key = self._key( + data_type="installer", + enterprise_id=enterprise_id, + is_enterprise_install=is_enterprise_install, + team_id=team_id, + user_id=user_id, + ) + try: + blob = self.bucket.blob(key) + body = blob.download_as_text(encoding="utf-8") + self.logger.debug("Downloaded %s from Google bucket", body) + data = json.loads(body) + return Installation(**data) + except Exception as exc: + self.logger.warning( + "Failed to find an installation data for enterprise: %s, team: %s: %s", enterprise_id, team_id, exc + ) + return None + + # + # adaptation of https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8 + # + + async def async_delete_installation( + self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None + ) -> None: + """Deletes a user's Slack installation data. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID + """ + self.delete_installation(enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) + + def delete_installation( + self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None + ) -> None: + """Deletes a user's Slack installation data. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID + """ + self._delete_entity(data_type="installer", enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) + self.logger.debug("Uninstalled app for enterprise: %s, team: %s, user: %s", enterprise_id, team_id, user_id) + + async def async_delete_bot(self, *, enterprise_id: Optional[str], team_id: Optional[str]) -> None: + """Deletes Slack bot user install data from the workspace. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + """ + self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) + + def delete_bot(self, *, enterprise_id: Optional[str], team_id: Optional[str]) -> None: + """Deletes Slack bot user install data from the workspace. + + Args: + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + """ + self._delete_entity(data_type="bot", enterprise_id=enterprise_id, team_id=team_id, user_id=None) + self.logger.debug("Uninstalled bot for enterprise: %s, team: %s", enterprise_id, team_id) + + def _delete_entity( + self, data_type: str, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] + ) -> None: + """Deletes an object from a Google Cloud Storage bucket. + + Args: + data_type (str): data type + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID + """ + key = self._key(data_type=data_type, enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) + blob = self.bucket.blob(key) + if blob.exists(): + blob.delete() + + def _key( + self, + data_type: str, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + is_enterprise_install: Optional[bool] = None, + ) -> str: + """Helper method to create a path to an object in a GCS bucket. + + Args: + data_type (str): object type + enterprise_id (Optional[str]): Slack Enterprise Grid ID + team_id (Optional[str]): Slack workspace/team ID + user_id (Optional[str]): Slack user ID + + Returns: + str: path to data corresponding to input args + """ + none = "none" + e_id = enterprise_id or none + t_id = none if is_enterprise_install else team_id or none + + workspace_path = f"{self.client_id}/{e_id}-{t_id}" + return f"{workspace_path}/{data_type}-{user_id}" if user_id else f"{workspace_path}/{data_type}" diff --git a/slack_sdk/oauth/state_store/google_cloud_storage/__init__.py b/slack_sdk/oauth/state_store/google_cloud_storage/__init__.py new file mode 100644 index 00000000..1ccb9942 --- /dev/null +++ b/slack_sdk/oauth/state_store/google_cloud_storage/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Store OAuth tokens/state in a Google Cloud Storage bucket.""" + +import logging +import time +from logging import Logger +from uuid import uuid4 + +from google.cloud.storage import Client # type: ignore[import-untyped] + +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.oauth.state_store import OAuthStateStore + + +class GoogleCloudStorageOAuthStateStore(OAuthStateStore, AsyncOAuthStateStore): + """Implements OAuthStateStore and AsyncOAuthStateStore for storing Slack bot auth data to Google Cloud Storage. + + Attributes: + storage_client (Client): A Google Cloud Storage client to access the bucket + bucket_name (str): Bucket to store OAuth data + expiration_seconds (int): expiration time for the Oauth token + """ + + def __init__( + self, + *, + storage_client: Client, + bucket_name: str, + expiration_seconds: int, + logger: Logger = logging.getLogger(__name__), + ): + """Creates a new instance. + + Args: + storage_client (Client): A Google Cloud Storage client to access the bucket + bucket_name (str): Bucket to store OAuth data + expiration_seconds (int): expiration time for the Oauth token + logger (Logger): Custom logger for logging. Defaults to a new logger for this module. + """ + self.storage_client = storage_client + self.bucket_name = bucket_name + self.expiration_seconds = expiration_seconds + self._logger = logger + + @property + def logger(self) -> Logger: + """Gets the internal logger if it exists, otherwise creates a new one. + + Returns: + Logger: the logger + """ + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + async def async_issue(self, *args, **kwargs) -> str: + """Creates and stores a new OAuth token. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + str: the token + """ + return self.issue(*args, **kwargs) + + async def async_consume(self, state: str) -> bool: + """Reads the token and checks if it's a valid one. + + Args: + state (str): the token + + Returns: + bool: True if the token is valid + """ + return self.consume(state) + + def issue(self, *args, **kwargs) -> str: + """Creates and stores a new OAuth token. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + str: the token + """ + state = str(uuid4()) + bucket = self.storage_client.bucket(self.bucket_name) + blob = bucket.blob(state) + blob.upload_from_string(str(time.time())) + self.logger.debug("Issued %s to the Google bucket", state) + return state + + def consume(self, state: str) -> bool: + """Reads the token and checks if it's a valid one. + + Args: + state (str): the token + + Returns: + bool: True if the token is valid + """ + try: + bucket = self.storage_client.bucket(self.bucket_name) + blob = bucket.blob(state) + body = blob.download_as_text(encoding="utf-8") + + self.logger.debug("Downloaded %s from Google bucket", state) + created = float(body) + expiration = created + self.expiration_seconds + still_valid: bool = time.time() < expiration + + blob.delete() + self.logger.debug("Deleted %s from Google bucket", state) + return still_valid + except Exception as exc: # pylint: disable=broad-except + self.logger.warning("Failed to find any persistent data for state: %s - %s", state, exc) + return False From 1b08f82b3b6b63548aae749e7570ba427e3b5389 Mon Sep 17 00:00:00 2001 From: Cristian Caruceru Date: Sun, 5 Jan 2025 18:30:54 +0100 Subject: [PATCH 2/5] add tests --- .../google_cloud_storage/__init__.py | 8 +- .../test_google_cloud_storage.py | 224 ++++++++++++++++++ .../state_store/test_google_cloud_storage.py | 77 ++++++ 3 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py create mode 100644 tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py diff --git a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py index 95a0a850..8c77828e 100644 --- a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py +++ b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py @@ -99,7 +99,7 @@ async def async_save_bot(self, bot: Bot): """Save bot user authorization. Args: - bot (Bot): data bout the bot + bot (Bot): data about the bot """ self.save_bot(bot) @@ -107,7 +107,7 @@ def save_bot(self, bot: Bot): """Save bot user authorization. Args: - bot (Bot): data bout the bot + bot (Bot): data about the bot """ entity = json.dumps(bot.__dict__) self._save_entity(data_type="bot", entity=entity, enterprise_id=bot.enterprise_id, team_id=bot.team_id, user_id=None) @@ -256,10 +256,6 @@ def find_installation( ) return None - # - # adaptation of https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8 - # - async def async_delete_installation( self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None ) -> None: diff --git a/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py new file mode 100644 index 00000000..23f497d3 --- /dev/null +++ b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +"""Tests for oauth/installation_store/google_cloud_storage/__init__.py""" + +import json +import time +import logging +import unittest +from unittest.mock import Mock, call, patch +from google.cloud.storage.blob import Blob +from google.cloud.storage.bucket import Bucket + +from google.cloud.storage.client import Client +from slack_sdk.oauth.installation_store.models.installation import Installation +from slack_sdk.oauth.installation_store.google_cloud_storage import GoogleCloudStorageInstallationStore + + +class TestGoogleInstallationStore(unittest.IsolatedAsyncioTestCase): + """Tests for GoogleCloudStorageInstallationStore""" + + async def asyncSetUp(self): + """Setup test""" + self.blob = Mock(spec=Blob) + self.bucket = Mock(spec=Bucket) + self.bucket.blob.return_value = self.blob + + self.storage_client = Mock(spec=Client) + self.storage_client.bucket.return_value = self.bucket + + self.bucket_name = "bucket" + self.client_id = "clid" + self.logger = logging.getLogger() + self.logger.handlers = [] + + self.installation_store = GoogleCloudStorageInstallationStore( + storage_client=self.storage_client, bucket_name=self.bucket_name, client_id=self.client_id, logger=self.logger + ) + + self.entr_installation = Installation(user_id="uid", team_id=None, is_enterprise_install=True, enterprise_id="eid") + self.team_installation = Installation(user_id="uid", team_id="tid", is_enterprise_install=False, enterprise_id=None) + + def test_get_logger(self): + """Test get_logger method""" + self.assertEqual(self.installation_store.logger, self.logger) + + @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") + async def test_async_save_install(self, save_entity: Mock): + """Test async_save method""" + await self.installation_store.async_save(self.entr_installation) + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + save_entity.assert_has_calls( + [ + call( + data_type="bot", + entity=json.dumps(self.entr_installation.to_bot().__dict__), + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=None, + ), + call( + data_type="installer", + entity=json.dumps(self.entr_installation.__dict__), + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=None, + ), + call( + data_type="installer", + entity=json.dumps(self.entr_installation.__dict__), + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=self.entr_installation.user_id, + ), + ] + ) + + @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") + async def test_async_save_bot(self, save_entity: Mock): + """Test async_save_bot method""" + await self.installation_store.async_save_bot(bot=self.entr_installation.to_bot()) + save_entity.assert_called_once_with( + data_type="bot", + entity=json.dumps(self.entr_installation.to_bot().__dict__), + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=None, + ) + + def test_save_entity_and_test_key(self): + """Test _save_entity and _key methods""" + entity = "some data" + # test upload user data enterprise install + normal workspace + for install in [self.entr_installation, self.team_installation]: + self.installation_store._save_entity( + data_type="dtype", + entity=entity, + enterprise_id=install.enterprise_id, + team_id=install.team_id, + user_id=install.user_id, + ) + self.bucket.blob.assert_called_once_with( + f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}" f"/dtype-{install.user_id}" + ) + self.blob.upload_from_string.assert_called_once_with(entity) + + self.bucket.reset_mock() + + # test upload user data enterprise install + normal workspace + for install in [self.entr_installation, self.team_installation]: + self.installation_store._save_entity( + data_type="dtype", entity=entity, enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=None + ) + self.bucket.blob.assert_called_once_with( + f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/dtype" + ) + + self.bucket.reset_mock() + + async def test_async_find_bot(self): + """Test async_find_bot method""" + self.blob.download_as_text.return_value = json.dumps( + {"bot_token": "xoxb-token", "bot_id": "bid", "bot_user_id": "buid", "installed_at": time.time()} + ) + # test bot found enterprise installation + normal workspace + for install in [self.entr_installation, self.team_installation]: + bot = await self.installation_store.async_find_bot( + enterprise_id=install.enterprise_id, + team_id=install.team_id, + is_enterprise_install=install.is_enterprise_install, + ) + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + self.bucket.blob.assert_called_once_with( + f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/bot" + ) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertIsNotNone(bot) + self.assertEqual(bot.bot_token, "xoxb-token") + + self.blob.reset_mock() + self.bucket.reset_mock() + + # test bot not found + self.blob.download_as_text.side_effect = Exception() + bot = await self.installation_store.async_find_bot( + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + is_enterprise_install=self.entr_installation.is_enterprise_install, + ) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertIsNone(bot) + + async def test_async_find_installation(self): + """Test async_find_installation method""" + self.blob.download_as_text.return_value = json.dumps({"user_id": self.entr_installation.user_id}) + # test installation found on enterprise install + normal workspace + for expect_install in [self.entr_installation, self.team_installation]: + actual_install = await self.installation_store.async_find_installation( + enterprise_id=expect_install.enterprise_id, + team_id=expect_install.team_id, + user_id=expect_install.user_id, + is_enterprise_install=expect_install.is_enterprise_install, + ) + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + self.bucket.blob.assert_called_once_with( + f"{self.client_id}/{expect_install.enterprise_id or 'none'}-{expect_install.team_id or 'none'}/" + f"installer-{expect_install.user_id}" + ) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertIsNotNone(actual_install) + self.assertEqual(actual_install.user_id, self.entr_installation.user_id) + + self.blob.reset_mock() + self.bucket.reset_mock() + + # test installation not found + self.blob.download_as_text.side_effect = Exception() + actual_install = await self.installation_store.async_find_installation( + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=self.entr_installation.user_id, + is_enterprise_install=self.entr_installation.is_enterprise_install, + ) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertIsNone(actual_install) + + async def test_async_delete_installation_and_test_delete_entity(self): + """Test async_delete_installation and test_delete_entity methods""" + self.blob.exists.return_value = True + # test delete enterprise install + normal workspace when blob exists + for install in [self.entr_installation, self.team_installation]: + await self.installation_store.async_delete_installation( + enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=install.user_id + ) + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + self.bucket.blob.assert_called_once_with( + f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/" + f"installer-{self.entr_installation.user_id}" + ) + self.blob.exists.assert_called_once() + self.blob.delete.assert_called_once() + + self.blob.reset_mock() + self.bucket.reset_mock() + + # test delete blob doesn't exist + self.blob.exists.return_value = False + await self.installation_store.async_delete_installation( + enterprise_id=self.entr_installation.enterprise_id, + team_id=self.entr_installation.team_id, + user_id=self.entr_installation.user_id, + ) + self.blob.exists.assert_called_once() + self.blob.delete.assert_not_called() + + @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._delete_entity") + async def test_async_delete_bot(self, delete_entity: Mock): + """Test async_delete_bot method""" + # test delete bot from enterprise install + normal workspace + for install in [self.entr_installation, self.team_installation]: + await self.installation_store.async_delete_bot(enterprise_id=install.enterprise_id, team_id=install.team_id) + delete_entity.assert_called_once_with( + data_type="bot", enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=None + ) + + delete_entity.reset_mock() diff --git a/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py b/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py new file mode 100644 index 00000000..2f7d0b73 --- /dev/null +++ b/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""Tests for oauth/state_store/google_cloud_storage/__init__.py""" + +import time +import logging +import unittest +from unittest.mock import Mock + +from google.cloud.storage.blob import Blob +from google.cloud.storage.bucket import Bucket +from google.cloud.storage.client import Client + +from slack_sdk.oauth.state_store.google_cloud_storage import GoogleCloudStorageOAuthStateStore + + +class TestGoogleStateStore(unittest.IsolatedAsyncioTestCase): + """Test GoogleCloudStorageOAuthStateStore class""" + + async def asyncSetUp(self): + """Setup tests""" + self.blob = Mock(spec=Blob) + self.blob.download_as_text.return_value = str(time.time()) + + self.bucket = Mock(spec=Bucket) + self.bucket.blob.return_value = self.blob + + self.storage_client = Mock(spec=Client) + self.storage_client.bucket.return_value = self.bucket + + self.logger = logging.getLogger() + self.logger.handlers = [] + + self.bucket_name = "bucket" + self.state_store = GoogleCloudStorageOAuthStateStore( + storage_client=self.storage_client, bucket_name=self.bucket_name, expiration_seconds=10, logger=self.logger + ) + + def test_get_logger(self): + """Test get_logger method""" + self.assertEqual(self.state_store.logger, self.logger) + + async def test_async_issue(self): + """Test async_issue method""" + state = await self.state_store.async_issue() + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + self.bucket.blob.assert_called_once() + self.assertEqual(self.bucket.blob.call_args.args[0], state) + self.blob.upload_from_string.assert_called_once() + self.assertRegex(self.blob.upload_from_string.call_args.args[0], r"\d{10,}.\d{5,}") + + async def test_async_comsume(self): + """Test async_comsume method""" + state = "state" + # test consume returns valid + valid = await self.state_store.async_consume(state=state) + self.storage_client.bucket.assert_called_once_with(self.bucket_name) + self.bucket.blob.assert_called_once_with(state) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertTrue(time.time() < float(self.blob.download_as_text.return_value) + self.state_store.expiration_seconds) + self.blob.delete.assert_called_once() + self.assertTrue(valid) + + self.blob.reset_mock() + + # test consume returns invalid + self.state_store.expiration_seconds = 0 + valid = await self.state_store.async_consume(state=state) + self.assertFalse(time.time() < float(self.blob.download_as_text.return_value) + self.state_store.expiration_seconds) + self.assertFalse(valid) + + self.blob.reset_mock() + + # test consume throw exception + self.blob.download_as_text.side_effect = Exception() + valid = await self.state_store.async_consume(state=state) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertFalse(valid) From 598c9286d094746489c0dfa35b2170ccd1975417 Mon Sep 17 00:00:00 2001 From: Cristian Caruceru Date: Sun, 5 Jan 2025 20:19:18 +0100 Subject: [PATCH 3/5] switch from async test to standard TestCase --- .../test_google_cloud_storage.py | 46 +++++++++---------- .../state_store/test_google_cloud_storage.py | 20 ++++---- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py index 23f497d3..082eb5e0 100644 --- a/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py +++ b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py @@ -14,10 +14,10 @@ from slack_sdk.oauth.installation_store.google_cloud_storage import GoogleCloudStorageInstallationStore -class TestGoogleInstallationStore(unittest.IsolatedAsyncioTestCase): +class TestGoogleInstallationStore(unittest.TestCase): """Tests for GoogleCloudStorageInstallationStore""" - async def asyncSetUp(self): + def setUp(self): """Setup test""" self.blob = Mock(spec=Blob) self.bucket = Mock(spec=Bucket) @@ -43,9 +43,9 @@ def test_get_logger(self): self.assertEqual(self.installation_store.logger, self.logger) @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") - async def test_async_save_install(self, save_entity: Mock): - """Test async_save method""" - await self.installation_store.async_save(self.entr_installation) + def test_save_install(self, save_entity: Mock): + """Test save method""" + self.installation_store.save(self.entr_installation) self.storage_client.bucket.assert_called_once_with(self.bucket_name) save_entity.assert_has_calls( [ @@ -74,9 +74,9 @@ async def test_async_save_install(self, save_entity: Mock): ) @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") - async def test_async_save_bot(self, save_entity: Mock): - """Test async_save_bot method""" - await self.installation_store.async_save_bot(bot=self.entr_installation.to_bot()) + def test_save_bot(self, save_entity: Mock): + """Test save_bot method""" + self.installation_store.save_bot(bot=self.entr_installation.to_bot()) save_entity.assert_called_once_with( data_type="bot", entity=json.dumps(self.entr_installation.to_bot().__dict__), @@ -115,14 +115,14 @@ def test_save_entity_and_test_key(self): self.bucket.reset_mock() - async def test_async_find_bot(self): - """Test async_find_bot method""" + def test_find_bot(self): + """Test find_bot method""" self.blob.download_as_text.return_value = json.dumps( {"bot_token": "xoxb-token", "bot_id": "bid", "bot_user_id": "buid", "installed_at": time.time()} ) # test bot found enterprise installation + normal workspace for install in [self.entr_installation, self.team_installation]: - bot = await self.installation_store.async_find_bot( + bot = self.installation_store.find_bot( enterprise_id=install.enterprise_id, team_id=install.team_id, is_enterprise_install=install.is_enterprise_install, @@ -140,7 +140,7 @@ async def test_async_find_bot(self): # test bot not found self.blob.download_as_text.side_effect = Exception() - bot = await self.installation_store.async_find_bot( + bot = self.installation_store.find_bot( enterprise_id=self.entr_installation.enterprise_id, team_id=self.entr_installation.team_id, is_enterprise_install=self.entr_installation.is_enterprise_install, @@ -148,12 +148,12 @@ async def test_async_find_bot(self): self.blob.download_as_text.assert_called_once_with(encoding="utf-8") self.assertIsNone(bot) - async def test_async_find_installation(self): - """Test async_find_installation method""" + def test_find_installation(self): + """Test find_installation method""" self.blob.download_as_text.return_value = json.dumps({"user_id": self.entr_installation.user_id}) # test installation found on enterprise install + normal workspace for expect_install in [self.entr_installation, self.team_installation]: - actual_install = await self.installation_store.async_find_installation( + actual_install = self.installation_store.find_installation( enterprise_id=expect_install.enterprise_id, team_id=expect_install.team_id, user_id=expect_install.user_id, @@ -173,7 +173,7 @@ async def test_async_find_installation(self): # test installation not found self.blob.download_as_text.side_effect = Exception() - actual_install = await self.installation_store.async_find_installation( + actual_install = self.installation_store.find_installation( enterprise_id=self.entr_installation.enterprise_id, team_id=self.entr_installation.team_id, user_id=self.entr_installation.user_id, @@ -182,12 +182,12 @@ async def test_async_find_installation(self): self.blob.download_as_text.assert_called_once_with(encoding="utf-8") self.assertIsNone(actual_install) - async def test_async_delete_installation_and_test_delete_entity(self): - """Test async_delete_installation and test_delete_entity methods""" + def test_delete_installation_and_test_delete_entity(self): + """Test delete_installation and test_delete_entity methods""" self.blob.exists.return_value = True # test delete enterprise install + normal workspace when blob exists for install in [self.entr_installation, self.team_installation]: - await self.installation_store.async_delete_installation( + self.installation_store.delete_installation( enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=install.user_id ) self.storage_client.bucket.assert_called_once_with(self.bucket_name) @@ -203,7 +203,7 @@ async def test_async_delete_installation_and_test_delete_entity(self): # test delete blob doesn't exist self.blob.exists.return_value = False - await self.installation_store.async_delete_installation( + self.installation_store.delete_installation( enterprise_id=self.entr_installation.enterprise_id, team_id=self.entr_installation.team_id, user_id=self.entr_installation.user_id, @@ -212,11 +212,11 @@ async def test_async_delete_installation_and_test_delete_entity(self): self.blob.delete.assert_not_called() @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._delete_entity") - async def test_async_delete_bot(self, delete_entity: Mock): - """Test async_delete_bot method""" + def test_delete_bot(self, delete_entity: Mock): + """Test delete_bot method""" # test delete bot from enterprise install + normal workspace for install in [self.entr_installation, self.team_installation]: - await self.installation_store.async_delete_bot(enterprise_id=install.enterprise_id, team_id=install.team_id) + self.installation_store.delete_bot(enterprise_id=install.enterprise_id, team_id=install.team_id) delete_entity.assert_called_once_with( data_type="bot", enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=None ) diff --git a/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py b/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py index 2f7d0b73..f0ccf24e 100644 --- a/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py +++ b/tests/slack_sdk/oauth/state_store/test_google_cloud_storage.py @@ -13,10 +13,10 @@ from slack_sdk.oauth.state_store.google_cloud_storage import GoogleCloudStorageOAuthStateStore -class TestGoogleStateStore(unittest.IsolatedAsyncioTestCase): +class TestGoogleStateStore(unittest.TestCase): """Test GoogleCloudStorageOAuthStateStore class""" - async def asyncSetUp(self): + def setUp(self): """Setup tests""" self.blob = Mock(spec=Blob) self.blob.download_as_text.return_value = str(time.time()) @@ -39,20 +39,20 @@ def test_get_logger(self): """Test get_logger method""" self.assertEqual(self.state_store.logger, self.logger) - async def test_async_issue(self): - """Test async_issue method""" - state = await self.state_store.async_issue() + def test_issue(self): + """Test issue method""" + state = self.state_store.issue() self.storage_client.bucket.assert_called_once_with(self.bucket_name) self.bucket.blob.assert_called_once() self.assertEqual(self.bucket.blob.call_args.args[0], state) self.blob.upload_from_string.assert_called_once() self.assertRegex(self.blob.upload_from_string.call_args.args[0], r"\d{10,}.\d{5,}") - async def test_async_comsume(self): - """Test async_comsume method""" + def test_consume(self): + """Test consume method""" state = "state" # test consume returns valid - valid = await self.state_store.async_consume(state=state) + valid = self.state_store.consume(state=state) self.storage_client.bucket.assert_called_once_with(self.bucket_name) self.bucket.blob.assert_called_once_with(state) self.blob.download_as_text.assert_called_once_with(encoding="utf-8") @@ -64,7 +64,7 @@ async def test_async_comsume(self): # test consume returns invalid self.state_store.expiration_seconds = 0 - valid = await self.state_store.async_consume(state=state) + valid = self.state_store.consume(state=state) self.assertFalse(time.time() < float(self.blob.download_as_text.return_value) + self.state_store.expiration_seconds) self.assertFalse(valid) @@ -72,6 +72,6 @@ async def test_async_comsume(self): # test consume throw exception self.blob.download_as_text.side_effect = Exception() - valid = await self.state_store.async_consume(state=state) + valid = self.state_store.consume(state=state) self.blob.download_as_text.assert_called_once_with(encoding="utf-8") self.assertFalse(valid) From b2f9f81377830d6a924ab9ff1f114ed738f6e99f Mon Sep 17 00:00:00 2001 From: Cristian Caruceru Date: Fri, 10 Jan 2025 18:43:18 +0100 Subject: [PATCH 4/5] tests from existing + adapt installation store --- .../google_cloud_storage/__init__.py | 52 +- .../test_google_cloud_storage.py | 507 +++++++++++------- 2 files changed, 361 insertions(+), 198 deletions(-) diff --git a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py index 8c77828e..6115f019 100644 --- a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py +++ b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py @@ -109,6 +109,10 @@ def save_bot(self, bot: Bot): Args: bot (Bot): data about the bot """ + if bot.bot_token is None: + self.logger.debug("Skipped saving bot install due to absense of bot token in it") + return + entity = json.dumps(bot.__dict__) self._save_entity(data_type="bot", entity=entity, enterprise_id=bot.enterprise_id, team_id=bot.team_id, user_id=None) self.logger.debug("Uploaded %s to Google bucket as bot", entity) @@ -249,7 +253,31 @@ def find_installation( body = blob.download_as_text(encoding="utf-8") self.logger.debug("Downloaded %s from Google bucket", body) data = json.loads(body) - return Installation(**data) + installation = Installation(**data) + + has_user_installation = user_id is not None and installation is not None + no_bot_token_installation = installation is not None and installation.bot_token is None + should_find_bot_installation = has_user_installation or no_bot_token_installation + if should_find_bot_installation: + # Retrieve the latest bot token, just in case + # See also: https://github.com/slackapi/bolt-python/issues/664 + latest_bot_installation = self.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=is_enterprise_install, + ) + if latest_bot_installation is not None and installation.bot_token != latest_bot_installation.bot_token: + # NOTE: this logic is based on the assumption that every single installation has bot scopes + # If you need to installation patterns without bot scopes in the same GCS bucket, + # please fork this code and implement your own logic. + installation.bot_id = latest_bot_installation.bot_id + installation.bot_user_id = latest_bot_installation.bot_user_id + installation.bot_token = latest_bot_installation.bot_token + installation.bot_scopes = latest_bot_installation.bot_scopes + installation.bot_refresh_token = latest_bot_installation.bot_refresh_token + installation.bot_token_expires_at = latest_bot_installation.bot_token_expires_at + + return installation except Exception as exc: self.logger.warning( "Failed to find an installation data for enterprise: %s, team: %s: %s", enterprise_id, team_id, exc @@ -271,15 +299,31 @@ async def async_delete_installation( def delete_installation( self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None ) -> None: - """Deletes a user's Slack installation data. + """Deletes a user's Slack installation data and any leftover installs. Args: enterprise_id (Optional[str]): Slack Enterprise Grid ID team_id (Optional[str]): Slack workspace/team ID user_id (Optional[str]): Slack user ID """ - self._delete_entity(data_type="installer", enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) - self.logger.debug("Uninstalled app for enterprise: %s, team: %s, user: %s", enterprise_id, team_id, user_id) + prefix = self._key(data_type="installer", enterprise_id=enterprise_id, team_id=team_id, user_id=None) + if user_id: + # delete the user install + self._delete_entity(data_type="installer", enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) + self.logger.debug("Uninstalled app for enterprise: %s, team: %s, user: %s", enterprise_id, team_id, user_id) + # list remaining installer* files + blobs = self.bucket.list_blobs(prefix=prefix, max_results=2) + # if just one blob and name is "installer" then delete it + if len(blobs) == 1 and blobs[0].name.endswith("installer"): + blobs[0].delete() + self.logger.debug("Uninstalled app for enterprise: %s, team: %s", enterprise_id, team_id) + else: + # delete the whole installation + blobs = self.bucket.list_blobs(prefix=prefix) + for blob in blobs: + blob.delete() + + self.logger.debug("Uninstalled app for enterprise: %s, team: %s, and all users", enterprise_id, team_id) async def async_delete_bot(self, *, enterprise_id: Optional[str], team_id: Optional[str]) -> None: """Deletes Slack bot user install data from the workspace. diff --git a/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py index 082eb5e0..a35f1e07 100644 --- a/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py +++ b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- """Tests for oauth/installation_store/google_cloud_storage/__init__.py""" -import json -import time -import logging import unittest -from unittest.mock import Mock, call, patch +from unittest.mock import Mock from google.cloud.storage.blob import Blob from google.cloud.storage.bucket import Bucket @@ -14,211 +11,333 @@ from slack_sdk.oauth.installation_store.google_cloud_storage import GoogleCloudStorageInstallationStore -class TestGoogleInstallationStore(unittest.TestCase): - """Tests for GoogleCloudStorageInstallationStore""" +class CloudStorageMockRecorder: + def __init__(self): + self.storage = {} # simulate cloud storage + + def mock_bucket_method(self, method_name: str): + """Mock bucket blob creation""" + + def wrapper(*args, **kwargs): + if method_name == "blob": + return self._make_blob_mock(args[0]) # make mock with the blob path when one is created + elif method_name == "list_blobs": + prefix = kwargs.get("prefix", "") + blob_names = [ # check how many recorded blobs start with the prefix + blob_name for blob_name in self.storage.keys() if blob_name.startswith(prefix) + ] + # return list of mocked blobs with the matched names + return [self._make_blob_mock(blob_name) for blob_name in blob_names] + + return wrapper + + def mock_blob_method(self, blob_name: str, method_name: str): + """Record blob activity""" + + def wrapper(*args, **kwargs): + if method_name == "upload_from_string": + self.storage[blob_name] = args[0] # blob value + elif method_name == "download_as_text": + return self.storage.get(blob_name, None) # return saved blob data or None + elif method_name == "delete": + self.storage.pop(blob_name, None) # remove saved blob if it exists + + return wrapper + + def _make_blob_mock(self, blob_name: str) -> Mock: + """Helper method to make a `Mock` of a `Blob`""" + blob_mock = Mock(spec=Blob) + blob_mock.name = blob_name + blob_mock.upload_from_string.side_effect = self.mock_blob_method(blob_name, "upload_from_string") + blob_mock.download_as_text.side_effect = self.mock_blob_method(blob_name, "download_as_text") + blob_mock.delete.side_effect = self.mock_blob_method(blob_name, "delete") + return blob_mock + +class TestGoogleInstallationStore(unittest.TestCase): def setUp(self): - """Setup test""" - self.blob = Mock(spec=Blob) + # self.blob = Mock(spec=Blob) self.bucket = Mock(spec=Bucket) - self.bucket.blob.return_value = self.blob + recorder = CloudStorageMockRecorder() + + self.bucket.blob.side_effect = recorder.mock_bucket_method("blob") + self.bucket.list_blobs.side_effect = recorder.mock_bucket_method("list_blobs") self.storage_client = Mock(spec=Client) self.storage_client.bucket.return_value = self.bucket - self.bucket_name = "bucket" - self.client_id = "clid" - self.logger = logging.getLogger() - self.logger.handlers = [] + def _build_store(self) -> GoogleCloudStorageInstallationStore: + return GoogleCloudStorageInstallationStore( + storage_client=self.storage_client, bucket_name="bucket_name", client_id="client_id" + ) - self.installation_store = GoogleCloudStorageInstallationStore( - storage_client=self.storage_client, bucket_name=self.bucket_name, client_id=self.client_id, logger=self.logger + def test_instance(self): + self.assertIsNotNone(self._build_store()) + + def test_save_and_find(self): + store = self._build_store() + installation = Installation( + app_id="A111", + enterprise_id="E111", + team_id="T111", + user_id="U111", + bot_id="B111", + bot_token="xoxb-111", + bot_scopes=["chat:write"], + bot_user_id="U222", ) + store.save(installation) + + # find bots + bot = store.find_bot(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(bot) + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + bot = store.find_bot(enterprise_id=None, team_id="T111") + self.assertIsNone(bot) + + # delete bots + store.delete_bot(enterprise_id="E111", team_id="T222") + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + + # find installations + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T222") + self.assertIsNone(i) + i = store.find_installation(enterprise_id=None, team_id="T111") + self.assertIsNone(i) - self.entr_installation = Installation(user_id="uid", team_id=None, is_enterprise_install=True, enterprise_id="eid") - self.team_installation = Installation(user_id="uid", team_id="tid", is_enterprise_install=False, enterprise_id=None) - - def test_get_logger(self): - """Test get_logger method""" - self.assertEqual(self.installation_store.logger, self.logger) - - @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") - def test_save_install(self, save_entity: Mock): - """Test save method""" - self.installation_store.save(self.entr_installation) - self.storage_client.bucket.assert_called_once_with(self.bucket_name) - save_entity.assert_has_calls( - [ - call( - data_type="bot", - entity=json.dumps(self.entr_installation.to_bot().__dict__), - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=None, - ), - call( - data_type="installer", - entity=json.dumps(self.entr_installation.__dict__), - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=None, - ), - call( - data_type="installer", - entity=json.dumps(self.entr_installation.__dict__), - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=self.entr_installation.user_id, - ), - ] + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U222") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T222", user_id="U111") + self.assertIsNone(i) + + # delete installations + store.delete_installation(enterprise_id="E111", team_id="T111", user_id="U111") + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNone(i) + + # delete all + store.save(installation) + store.delete_all(enterprise_id="E111", team_id="T111") + + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNone(i) + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + + def test_org_installation(self): + store = self._build_store() + installation = Installation( + app_id="AO111", + enterprise_id="EO111", + user_id="UO111", + bot_id="BO111", + bot_token="xoxb-O111", + bot_scopes=["chat:write"], + bot_user_id="UO222", + is_enterprise_install=True, ) + store.save(installation) + + # find bots + bot = store.find_bot(enterprise_id="EO111", team_id=None) + self.assertIsNotNone(bot) + bot = store.find_bot(enterprise_id="EO111", team_id="TO222", is_enterprise_install=True) + self.assertIsNotNone(bot) + bot = store.find_bot(enterprise_id="EO111", team_id="TO222") + self.assertIsNone(bot) + bot = store.find_bot(enterprise_id=None, team_id="TO111") + self.assertIsNone(bot) + + # delete bots + store.delete_bot(enterprise_id="EO111", team_id="TO222") + bot = store.find_bot(enterprise_id="EO111", team_id=None) + self.assertIsNotNone(bot) + + store.delete_bot(enterprise_id="EO111", team_id=None) + bot = store.find_bot(enterprise_id="EO111", team_id=None) + self.assertIsNone(bot) - @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._save_entity") - def test_save_bot(self, save_entity: Mock): - """Test save_bot method""" - self.installation_store.save_bot(bot=self.entr_installation.to_bot()) - save_entity.assert_called_once_with( - data_type="bot", - entity=json.dumps(self.entr_installation.to_bot().__dict__), - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=None, + # find installations + i = store.find_installation(enterprise_id="EO111", team_id=None) + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="EO111", team_id="T111", is_enterprise_install=True) + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="EO111", team_id="T222") + self.assertIsNone(i) + i = store.find_installation(enterprise_id=None, team_id="T111") + self.assertIsNone(i) + + i = store.find_installation(enterprise_id="EO111", team_id=None, user_id="UO111") + self.assertIsNotNone(i) + i = store.find_installation( + enterprise_id="E111", + team_id="T111", + is_enterprise_install=True, + user_id="U222", ) + self.assertIsNone(i) + i = store.find_installation(enterprise_id=None, team_id="T222", user_id="U111") + self.assertIsNone(i) + + # delete installations + store.delete_installation(enterprise_id="E111", team_id=None) + i = store.find_installation(enterprise_id="E111", team_id=None) + self.assertIsNone(i) + + # delete all + store.save(installation) + store.delete_all(enterprise_id="E111", team_id=None) + + i = store.find_installation(enterprise_id="E111", team_id=None) + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id=None, user_id="U111") + self.assertIsNone(i) + bot = store.find_bot(enterprise_id=None, team_id="T222") + self.assertIsNone(bot) - def test_save_entity_and_test_key(self): - """Test _save_entity and _key methods""" - entity = "some data" - # test upload user data enterprise install + normal workspace - for install in [self.entr_installation, self.team_installation]: - self.installation_store._save_entity( - data_type="dtype", - entity=entity, - enterprise_id=install.enterprise_id, - team_id=install.team_id, - user_id=install.user_id, - ) - self.bucket.blob.assert_called_once_with( - f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}" f"/dtype-{install.user_id}" - ) - self.blob.upload_from_string.assert_called_once_with(entity) - - self.bucket.reset_mock() - - # test upload user data enterprise install + normal workspace - for install in [self.entr_installation, self.team_installation]: - self.installation_store._save_entity( - data_type="dtype", entity=entity, enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=None - ) - self.bucket.blob.assert_called_once_with( - f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/dtype" - ) - - self.bucket.reset_mock() - - def test_find_bot(self): - """Test find_bot method""" - self.blob.download_as_text.return_value = json.dumps( - {"bot_token": "xoxb-token", "bot_id": "bid", "bot_user_id": "buid", "installed_at": time.time()} + def test_save_and_find_token_rotation(self): + store = self._build_store() + installation = Installation( + app_id="A111", + enterprise_id="E111", + team_id="T111", + user_id="U111", + bot_id="B111", + bot_token="xoxe.xoxp-1-initial", + bot_scopes=["chat:write"], + bot_user_id="U222", + bot_refresh_token="xoxe-1-initial", + bot_token_expires_in=43200, ) - # test bot found enterprise installation + normal workspace - for install in [self.entr_installation, self.team_installation]: - bot = self.installation_store.find_bot( - enterprise_id=install.enterprise_id, - team_id=install.team_id, - is_enterprise_install=install.is_enterprise_install, - ) - self.storage_client.bucket.assert_called_once_with(self.bucket_name) - self.bucket.blob.assert_called_once_with( - f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/bot" - ) - self.blob.download_as_text.assert_called_once_with(encoding="utf-8") - self.assertIsNotNone(bot) - self.assertEqual(bot.bot_token, "xoxb-token") - - self.blob.reset_mock() - self.bucket.reset_mock() - - # test bot not found - self.blob.download_as_text.side_effect = Exception() - bot = self.installation_store.find_bot( - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - is_enterprise_install=self.entr_installation.is_enterprise_install, + store.save(installation) + + bot = store.find_bot(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(bot) + self.assertEqual(bot.bot_refresh_token, "xoxe-1-initial") + + # Update the existing data + refreshed_installation = Installation( + app_id="A111", + enterprise_id="E111", + team_id="T111", + user_id="U111", + bot_id="B111", + bot_token="xoxe.xoxp-1-refreshed", + bot_scopes=["chat:write"], + bot_user_id="U222", + bot_refresh_token="xoxe-1-refreshed", + bot_token_expires_in=43200, ) - self.blob.download_as_text.assert_called_once_with(encoding="utf-8") - self.assertIsNone(bot) - - def test_find_installation(self): - """Test find_installation method""" - self.blob.download_as_text.return_value = json.dumps({"user_id": self.entr_installation.user_id}) - # test installation found on enterprise install + normal workspace - for expect_install in [self.entr_installation, self.team_installation]: - actual_install = self.installation_store.find_installation( - enterprise_id=expect_install.enterprise_id, - team_id=expect_install.team_id, - user_id=expect_install.user_id, - is_enterprise_install=expect_install.is_enterprise_install, - ) - self.storage_client.bucket.assert_called_once_with(self.bucket_name) - self.bucket.blob.assert_called_once_with( - f"{self.client_id}/{expect_install.enterprise_id or 'none'}-{expect_install.team_id or 'none'}/" - f"installer-{expect_install.user_id}" - ) - self.blob.download_as_text.assert_called_once_with(encoding="utf-8") - self.assertIsNotNone(actual_install) - self.assertEqual(actual_install.user_id, self.entr_installation.user_id) - - self.blob.reset_mock() - self.bucket.reset_mock() - - # test installation not found - self.blob.download_as_text.side_effect = Exception() - actual_install = self.installation_store.find_installation( - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=self.entr_installation.user_id, - is_enterprise_install=self.entr_installation.is_enterprise_install, + store.save(refreshed_installation) + + # find bots + bot = store.find_bot(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(bot) + self.assertEqual(bot.bot_refresh_token, "xoxe-1-refreshed") + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + bot = store.find_bot(enterprise_id=None, team_id="T111") + self.assertIsNone(bot) + + # delete bots + store.delete_bot(enterprise_id="E111", team_id="T222") + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + + # find installations + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T222") + self.assertIsNone(i) + i = store.find_installation(enterprise_id=None, team_id="T111") + self.assertIsNone(i) + + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNotNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U222") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T222", user_id="U111") + self.assertIsNone(i) + + # delete installations + store.delete_installation(enterprise_id="E111", team_id="T111", user_id="U111") + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNone(i) + + # delete all + store.save(installation) + store.delete_all(enterprise_id="E111", team_id="T111") + + i = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNone(i) + i = store.find_installation(enterprise_id="E111", team_id="T111", user_id="U111") + self.assertIsNone(i) + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + + def test_issue_1441_mixing_user_and_bot_installations(self): + store = self._build_store() + + bot_installation = Installation( + app_id="A111", + enterprise_id="E111", + team_id="T111", + user_id="U111", + bot_id="B111", + bot_token="xoxb-111", + bot_scopes=["chat:write"], + bot_user_id="U222", ) - self.blob.download_as_text.assert_called_once_with(encoding="utf-8") - self.assertIsNone(actual_install) - - def test_delete_installation_and_test_delete_entity(self): - """Test delete_installation and test_delete_entity methods""" - self.blob.exists.return_value = True - # test delete enterprise install + normal workspace when blob exists - for install in [self.entr_installation, self.team_installation]: - self.installation_store.delete_installation( - enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=install.user_id - ) - self.storage_client.bucket.assert_called_once_with(self.bucket_name) - self.bucket.blob.assert_called_once_with( - f"{self.client_id}/{install.enterprise_id or 'none'}-{install.team_id or 'none'}/" - f"installer-{self.entr_installation.user_id}" - ) - self.blob.exists.assert_called_once() - self.blob.delete.assert_called_once() - - self.blob.reset_mock() - self.bucket.reset_mock() - - # test delete blob doesn't exist - self.blob.exists.return_value = False - self.installation_store.delete_installation( - enterprise_id=self.entr_installation.enterprise_id, - team_id=self.entr_installation.team_id, - user_id=self.entr_installation.user_id, + store.save(bot_installation) + + # find bots + bot = store.find_bot(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(bot) + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + bot = store.find_bot(enterprise_id=None, team_id="T111") + self.assertIsNone(bot) + + installation = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(installation.bot_token) + installation = store.find_installation(enterprise_id="E111", team_id="T222") + self.assertIsNone(installation) + installation = store.find_installation(enterprise_id=None, team_id="T111") + self.assertIsNone(installation) + + user_installation = Installation( + app_id="A111", + enterprise_id="E111", + team_id="T111", + user_id="U111", + bot_scopes=["openid"], + user_token="xoxp-111", ) - self.blob.exists.assert_called_once() - self.blob.delete.assert_not_called() - - @patch("slack_sdk.oauth.installation_store.google_cloud_storage.GoogleCloudStorageInstallationStore._delete_entity") - def test_delete_bot(self, delete_entity: Mock): - """Test delete_bot method""" - # test delete bot from enterprise install + normal workspace - for install in [self.entr_installation, self.team_installation]: - self.installation_store.delete_bot(enterprise_id=install.enterprise_id, team_id=install.team_id) - delete_entity.assert_called_once_with( - data_type="bot", enterprise_id=install.enterprise_id, team_id=install.team_id, user_id=None - ) - - delete_entity.reset_mock() + store.save(user_installation) + + # find bots + bot = store.find_bot(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(bot.bot_token) + bot = store.find_bot(enterprise_id="E111", team_id="T222") + self.assertIsNone(bot) + bot = store.find_bot(enterprise_id=None, team_id="T111") + self.assertIsNone(bot) + + installation = store.find_installation(enterprise_id="E111", team_id="T111") + self.assertIsNotNone(installation.bot_token) + installation = store.find_installation(enterprise_id="E111", team_id="T222") + self.assertIsNone(installation) + installation = store.find_installation(enterprise_id=None, team_id="T111") + self.assertIsNone(installation) From 620d13bd7725998259ea0dfd7366a4aa5876f628 Mon Sep 17 00:00:00 2001 From: Cristian Caruceru Date: Fri, 10 Jan 2025 19:26:59 +0100 Subject: [PATCH 5/5] wrap blob iterator in a list --- .../oauth/installation_store/google_cloud_storage/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py index 6115f019..ae7a1ed7 100644 --- a/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py +++ b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py @@ -312,7 +312,7 @@ def delete_installation( self._delete_entity(data_type="installer", enterprise_id=enterprise_id, team_id=team_id, user_id=user_id) self.logger.debug("Uninstalled app for enterprise: %s, team: %s, user: %s", enterprise_id, team_id, user_id) # list remaining installer* files - blobs = self.bucket.list_blobs(prefix=prefix, max_results=2) + blobs = list(self.bucket.list_blobs(prefix=prefix, max_results=2)) # if just one blob and name is "installer" then delete it if len(blobs) == 1 and blobs[0].name.endswith("installer"): blobs[0].delete()