From 46710f650bf6c35d30b06936ab087d604a6381e9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 14:16:33 -0400 Subject: [PATCH] use a global context --- app.py | 21 ++++++------- jira/client.py | 4 ++- listeners/actions/disconnect_account.py | 4 +-- listeners/events/app_home/app_home_open.py | 15 ++++----- listeners/functions/create_issue.py | 23 +++++++++++--- manifest.json | 2 +- tests/functions/test_create_issue.py | 32 +++++++++---------- tests/mock_jira_oauth_state_store.py | 28 +++++++++++++++++ tests/utils.py | 16 ++++++++++ utils/constants.py | 5 ++- utils/context.py | 36 ++++++++++++++++++++++ utils/env_variables.py | 9 ------ 12 files changed, 136 insertions(+), 59 deletions(-) create mode 100644 tests/mock_jira_oauth_state_store.py create mode 100644 utils/context.py delete mode 100644 utils/env_variables.py diff --git a/app.py b/app.py index a41ad7d..549ab50 100644 --- a/app.py +++ b/app.py @@ -7,11 +7,8 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.file import JiraFileOAuthStateStore from listeners import register_listeners -from utils.constants import JIRA_CODE_VERIFIER, OAUTH_REDIRECT_PATH -from utils.env_variables import APP_HOME_PAGE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_REDIRECT_URI +from utils.constants import CONTEXT logging.basicConfig(level=logging.INFO) @@ -22,7 +19,7 @@ register_listeners(app) -@flask_app.route(OAUTH_REDIRECT_PATH, methods=["GET"]) +@flask_app.route(CONTEXT.jira_oauth_redirect_path, methods=["GET"]) def oauth_redirect(): code = request.args["code"] state = request.args["state"] @@ -30,20 +27,20 @@ def oauth_redirect(): jira_client = JiraClient() jira_resp = jira_client.oauth2_token( code=code, - client_id=JIRA_CLIENT_ID, - client_secret=JIRA_CLIENT_SECRET, - code_verifier=JIRA_CODE_VERIFIER, - redirect_uri=JIRA_REDIRECT_URI, + client_id=CONTEXT.jira_client_id, + client_secret=CONTEXT.jira_client_secret, + code_verifier=CONTEXT.jira_code_verifier, + redirect_uri=CONTEXT.jira_redirect_uri, ) jira_resp.raise_for_status() jira_resp_json = jira_resp.json() - user_identity = JiraFileOAuthStateStore().consume(state) + user_identity = CONTEXT.jira_state_store.consume(state) if user_identity is None: return make_response("State Not Found", 404) - JiraFileInstallationStore().save( + CONTEXT.jira_installation_store.save( { "access_token": jira_resp_json["access_token"], "enterprise_id": user_identity["enterprise_id"], @@ -56,7 +53,7 @@ def oauth_redirect(): "user_id": user_identity["user_id"], } ) - return redirect(APP_HOME_PAGE_URL, code=302) + return redirect(CONTEXT.app_home_page_url, code=302) if __name__ == "__main__": diff --git a/jira/client.py b/jira/client.py index 300dc12..e588a1a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -6,6 +6,8 @@ import requests from requests import Response +from utils.constants import CONTEXT + class JiraClient: def __init__( @@ -17,7 +19,7 @@ def __init__( proxies: Optional[Dict[str, str]] = None, ): self.token = token - self.base_url = base_url or os.environ.get("JIRA_BASE_URL") + self.base_url = base_url or CONTEXT.jira_base_url self.token_type = token_type self.headers = headers or {} self.headers[os.getenv("SECRET_HEADER_KEY")] = os.getenv("SECRET_HEADER_VALUE") diff --git a/listeners/actions/disconnect_account.py b/listeners/actions/disconnect_account.py index 33d6635..3f17291 100644 --- a/listeners/actions/disconnect_account.py +++ b/listeners/actions/disconnect_account.py @@ -1,10 +1,10 @@ from slack_bolt import Ack, BoltContext -from jira.oauth.installation_store.file import JiraFileInstallationStore +from utils.constants import CONTEXT def disconnect_account_callback(ack: Ack, context: BoltContext): ack() - JiraFileInstallationStore().delete_installation( + CONTEXT.jira_installation_store.delete_installation( enterprise_id=context.enterprise_id, team_id=context.team_id, user_id=context.user_id ) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 8598b70..977a6f9 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -4,11 +4,8 @@ from slack_sdk import WebClient from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.file import JiraFileOAuthStateStore from jira.oauth.state_store.models import JiraUserIdentity -from utils.constants import JIRA_CODE_VERIFIER -from utils.env_variables import JIRA_CLIENT_ID, JIRA_REDIRECT_URI +from utils.constants import CONTEXT from .builder import AppHomeBuilder @@ -19,22 +16,22 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte return try: home = AppHomeBuilder() - installation = JiraFileInstallationStore().find_installation( + installation = CONTEXT.jira_installation_store.find_installation( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: - state = JiraFileOAuthStateStore().issue( + state = CONTEXT.jira_state_store.issue( user_identity=JiraUserIdentity( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) ) jira_client = JiraClient() authorization_url = jira_client.build_authorization_url( - client_id=JIRA_CLIENT_ID, - redirect_uri=JIRA_REDIRECT_URI, + client_id=CONTEXT.jira_client_id, + redirect_uri=CONTEXT.jira_redirect_uri, scope="WRITE", - code_challenge=JIRA_CODE_VERIFIER, + code_challenge=CONTEXT.jira_code_verifier, state=state, ) home.add_connect_account_button(authorization_url) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 853db9f..c839fec 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore +from utils.constants import CONTEXT # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ @@ -16,15 +16,30 @@ def create_issue_callback( ack() user_id = inputs["user_context"]["id"] - installation = JiraFileInstallationStore().find_installation( + installation = CONTEXT.jira_installation_store.find_installation( user_id=user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: + mrkdwn: str = ( + ":no_entry: There is a problem with the `Create an issue` step, you did not connect a Jira Account, " + f"visit the <{CONTEXT.app_home_page_url}|App Home Page> to fix this!" + ) client.chat_postMessage( channel=user_id, - text="The function failed because the is no connected jira account, visit the app home to solve this", + text=mrkdwn, + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": mrkdwn, + }, + } + ], + ) + return fail( + f"User {user_id} has not connected their account properly, they must visit the App Home Page to fix this" ) - return fail(f"User {user_id} has not connected their account properly, visit the app home to solve this") try: project: str = inputs["project"] diff --git a/manifest.json b/manifest.json index dccd2a7..0042989 100644 --- a/manifest.json +++ b/manifest.json @@ -20,7 +20,7 @@ "display_name": "BoltPy Jira Functions" }, "app_home": { - "messages_tab_enabled": false, + "messages_tab_enabled": true, "home_tab_enabled": true } }, diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index 827b624..bc0058e 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -1,14 +1,13 @@ import json import logging -import os from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import requests +from slack_sdk import WebClient from listeners.functions.create_issue import create_issue_callback -from tests.mock_jira_installation_store import MockJiraInstallationStore -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import build_mock_context, remove_os_env_temporarily, restore_os_env def mock_response(status=200, data: dict = None): @@ -24,9 +23,10 @@ class TestCreateIssue: team_id = "T1234" enterprise_id = "E1234" - def build_mock_installation_store(self): - installation_store = MockJiraInstallationStore() - installation_store.save( + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + self.mock_context = build_mock_context() + self.mock_context.jira_installation_store.save( { "scope": "WRITE", "access_token": "jira_access_token", @@ -39,18 +39,14 @@ def build_mock_installation_store(self): "installed_at": datetime.now().timestamp(), } ) - return installation_store - - def setup_method(self): - self.old_os_env = remove_os_env_temporarily() - os.environ["JIRA_BASE_URL"] = "https://jira-dev/" - self.mock_installation_store = patch( - "listeners.functions.create_issue.JiraFileInstallationStore", self.build_mock_installation_store - ) - self.mock_installation_store.start() + self.mock_create_issue_context = patch("listeners.functions.create_issue.CONTEXT", self.mock_context) + self.mock_jira_client_context = patch("jira.client.CONTEXT", self.mock_context) + self.mock_jira_client_context.start() + self.mock_create_issue_context.start() def teardown_method(self): - self.mock_installation_store.stop() + self.mock_create_issue_context.stop() + self.mock_jira_client_context.stop() restore_os_env(self.old_os_env) def test_create_issue(self): @@ -97,7 +93,7 @@ def test_create_issue_fail(self): mock_fail = MagicMock() mock_complete = MagicMock() mock_context = MagicMock(team_id=self.team_id, enterprise_id=self.enterprise_id) - mock_client = MagicMock(chat_postMessage=lambda channel, text: True) + mock_client = Mock(spec=WebClient) mock_inputs = { "user_context": {"id": "wrong_id"}, "project": "PROJ", diff --git a/tests/mock_jira_oauth_state_store.py b/tests/mock_jira_oauth_state_store.py new file mode 100644 index 0000000..061c3af --- /dev/null +++ b/tests/mock_jira_oauth_state_store.py @@ -0,0 +1,28 @@ +import logging +import uuid +from logging import Logger +from typing import Dict, Optional + +from jira.oauth.state_store.models import JiraUserIdentity +from jira.oauth.state_store.state_store import JiraOAuthStateStore + + +class MockJiraOAuthStateStore(JiraOAuthStateStore): + def __init__( + self, + *, + logger: Logger = logging.getLogger(__name__), + ): + self.state_table: Dict[str, JiraUserIdentity] = {} + self.logger = logger + + def issue(self, user_identity: JiraUserIdentity) -> str: + state = uuid.uuid4().hex + self.state_table[state] = user_identity + return state + + def consume(self, state: str) -> Optional[JiraUserIdentity]: + if state not in self.state_table: + message = f"Failed to find any persistent data for state: {state} - Key Error" + self.logger.warning(message) + return self.state_table.pop(state, None) diff --git a/tests/utils.py b/tests/utils.py index 185e41b..a84a44e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,9 @@ import os +from tests.mock_jira_installation_store import MockJiraInstallationStore +from tests.mock_jira_oauth_state_store import MockJiraOAuthStateStore +from utils.context import Context + def remove_os_env_temporarily() -> dict: old_env = os.environ.copy() @@ -9,3 +13,15 @@ def remove_os_env_temporarily() -> dict: def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) + + +def build_mock_context() -> Context: + return Context( + jira_base_url="https://jira-dev/", + jira_client_id="abc123_id", + jira_client_secret="abc123_secret", + app_base_url="http://127.0.0.1:3000", + app_home_page_url="slack://app?team=T123&id=A123&tab=home", + jira_installation_store=MockJiraInstallationStore(), + jira_state_store=MockJiraOAuthStateStore(), + ) diff --git a/utils/constants.py b/utils/constants.py index 3dd6b6d..fa54b3f 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,4 +1,3 @@ -import secrets +from utils.context import Context -OAUTH_REDIRECT_PATH = "/oauth/redirect" -JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] +CONTEXT = Context() diff --git a/utils/context.py b/utils/context.py new file mode 100644 index 0000000..06bf9a4 --- /dev/null +++ b/utils/context.py @@ -0,0 +1,36 @@ +import os +import secrets +from typing import Optional +from urllib.parse import urljoin + +from jira.oauth.installation_store.file import JiraFileInstallationStore +from jira.oauth.installation_store.installation_store import JiraInstallationStore +from jira.oauth.state_store.file import JiraFileOAuthStateStore +from jira.oauth.state_store.state_store import JiraOAuthStateStore + + +class Context: + def __init__( + self, + jira_base_url: Optional[str] = None, + jira_client_id: Optional[str] = None, + jira_client_secret: Optional[str] = None, + jira_code_verifier: Optional[str] = None, + app_base_url: Optional[str] = None, + jira_oauth_redirect_path: str = "/oauth/redirect", + app_home_page_url: Optional[str] = None, + jira_state_store: JiraOAuthStateStore = JiraFileOAuthStateStore(), + jira_installation_store: JiraInstallationStore = JiraFileInstallationStore(), + ): + self.jira_base_url = jira_base_url or os.getenv("JIRA_BASE_URL") + self.jira_client_id = jira_client_id or os.getenv("JIRA_CLIENT_ID") + self.jira_client_secret = jira_client_secret or os.getenv("JIRA_CLIENT_SECRET") + self.app_base_url = app_base_url or os.getenv("APP_BASE_URL") + self.app_home_page_url = app_home_page_url or os.getenv("APP_HOME_PAGE_URL") + + self.jira_code_verifier = jira_code_verifier or secrets.token_urlsafe(96)[:128] + self.jira_oauth_redirect_path = jira_oauth_redirect_path + self.jira_redirect_uri = urljoin(self.app_base_url, self.jira_oauth_redirect_path) + + self.jira_state_store = jira_state_store + self.jira_installation_store = jira_installation_store diff --git a/utils/env_variables.py b/utils/env_variables.py deleted file mode 100644 index fea8875..0000000 --- a/utils/env_variables.py +++ /dev/null @@ -1,9 +0,0 @@ -import os -from urllib.parse import urljoin - -from utils.constants import OAUTH_REDIRECT_PATH - -JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") -JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") -JIRA_REDIRECT_URI = urljoin(os.getenv("APP_BASE_URL"), OAUTH_REDIRECT_PATH) -APP_HOME_PAGE_URL = os.getenv("APP_HOME_PAGE_URL")