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..ae7a1ed7 --- /dev/null +++ b/slack_sdk/oauth/installation_store/google_cloud_storage/__init__.py @@ -0,0 +1,387 @@ +# -*- 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 about the bot + """ + self.save_bot(bot) + + def save_bot(self, bot: Bot): + """Save bot user authorization. + + 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) + + 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) + 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 + ) + return None + + 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 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 + """ + 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 = 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() + 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. + + 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 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..a35f1e07 --- /dev/null +++ b/tests/slack_sdk/oauth/installation_store/test_google_cloud_storage.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +"""Tests for oauth/installation_store/google_cloud_storage/__init__.py""" + +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.installation_store.models.installation import Installation +from slack_sdk.oauth.installation_store.google_cloud_storage import 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): + # self.blob = Mock(spec=Blob) + self.bucket = Mock(spec=Bucket) + 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 + + def _build_store(self) -> GoogleCloudStorageInstallationStore: + return GoogleCloudStorageInstallationStore( + storage_client=self.storage_client, bucket_name="bucket_name", client_id="client_id" + ) + + 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) + + 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) + + # 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_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, + ) + 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, + ) + 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", + ) + 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", + ) + 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) 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..f0ccf24e --- /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.TestCase): + """Test GoogleCloudStorageOAuthStateStore class""" + + def setUp(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) + + 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,}") + + def test_consume(self): + """Test consume method""" + state = "state" + # test consume returns valid + 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") + 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 = 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) + + self.blob.reset_mock() + + # test consume throw exception + self.blob.download_as_text.side_effect = Exception() + valid = self.state_store.consume(state=state) + self.blob.download_as_text.assert_called_once_with(encoding="utf-8") + self.assertFalse(valid)