From cc3d10f3402e2dd0a2b7b3fa83ff7c638190d290 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:44:52 -0500 Subject: [PATCH] Feature/generic webhook (#1020) * added api endpoint for a generic webhook w/ burnettk * added the start of test for testing webhooks w/ burnettk * the initial test for webhooks is now working w/ burnettk * added test to prove we can run a message send from a non-persistent process instance w/ burnettk * pyl w/ burnettk * updated connector-http for patch command w/ burnettk * make the webhook persistent so the message instance can be created w/ burnettk * make sure we commit the message instance to the db in the webhook code w/ burnettk --------- Co-authored-by: jasquat --- connector-proxy-demo/poetry.lock | 4 +- .../src/spiffworkflow_backend/api.yml | 21 +++++- .../spiffworkflow_backend/config/default.py | 9 +++ .../config/unit_testing.py | 3 + .../src/spiffworkflow_backend/models/user.py | 1 + .../routes/extensions_controller.py | 68 +++--------------- .../routes/process_api_blueprint.py | 32 ++++----- .../routes/process_instances_controller.py | 3 +- .../routes/webhooks_controller.py | 59 ++++++++++++++++ .../services/authorization_service.py | 1 + .../services/message_service.py | 8 ++- .../services/process_instance_service.py | 69 ++++++++++++++++++- .../services/user_service.py | 21 ++++-- .../message_start_event.bpmn | 41 +++++++++++ .../process_model.json | 9 +++ .../simple-message-send-receive.bpmn | 64 +++++++++++++++++ .../integration/test_process_api.py | 3 - .../integration/test_webhooks_controller.py | 36 ++++++++++ .../unit/test_message_service.py | 45 ++++++++++++ 19 files changed, 407 insertions(+), 90 deletions(-) create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/routes/webhooks_controller.py create mode 100644 spiffworkflow-backend/tests/data/simple-message-send-receive/message_start_event.bpmn create mode 100644 spiffworkflow-backend/tests/data/simple-message-send-receive/process_model.json create mode 100644 spiffworkflow-backend/tests/data/simple-message-send-receive/simple-message-send-receive.bpmn create mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_webhooks_controller.py diff --git a/connector-proxy-demo/poetry.lock b/connector-proxy-demo/poetry.lock index 98c0298f..0c1a68a7 100644 --- a/connector-proxy-demo/poetry.lock +++ b/connector-proxy-demo/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "boto3" @@ -220,7 +220,7 @@ spiffworkflow-connector-command = {git = "https://github.com/sartography/spiffwo type = "git" url = "https://github.com/sartography/connector-http.git" reference = "HEAD" -resolved_reference = "026de90d01e1127b7944600818aa94dc53850518" +resolved_reference = "5e6a675a421cbee85f9c33832b874152a2a57e1e" [[package]] name = "connector-slack" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 5cfd234b..7695b0a1 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -747,7 +747,7 @@ paths: /github-webhook-receive: post: - operationId: spiffworkflow_backend.routes.process_api_blueprint.github_webhook_receive + operationId: spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive summary: receives push webhooks from github so we can keep our process model repo up to date requestBody: content: @@ -764,6 +764,25 @@ paths: schema: $ref: "#/components/schemas/OkTrue" + /webhook: + post: + operationId: spiffworkflow_backend.routes.webhooks_controller.webhook + summary: receives webhooks from external systems and runs a process model using the data received from the caller so arbitrary handling can be achieved. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + tags: + - git + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + /process-instances/for-me: parameters: - name: process_model_identifier diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 4716aacf..394c0164 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -165,6 +165,15 @@ config_from_env("SPIFFWORKFLOW_BACKEND_GIT_USER_EMAIL") config_from_env("SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET") config_from_env("SPIFFWORKFLOW_BACKEND_GIT_SSH_PRIVATE_KEY_PATH") +### webhook +# configs for handling incoming webhooks from other systems +# it assumes github webhooks by default, since SPIFFWORKFLOW_BACKEND_WEBHOOK_ENFORCES_GITHUB_AUTH is true, +# but if you set that to false, you can handle webhooks from any system. just make sure you supply your +# own auth checks in the process model. +# the github auth will use SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET from above. +config_from_env("SPIFFWORKFLOW_BACKEND_WEBHOOK_ENFORCES_GITHUB_AUTH", default=True) +config_from_env("SPIFFWORKFLOW_BACKEND_WEBHOOK_PROCESS_MODEL_IDENTIFIER") + ### element units # disabling until we fix the "no such directory" error so we do not keep sending cypress errors config_from_env("SPIFFWORKFLOW_BACKEND_ELEMENT_UNITS_CACHE_DIR", default="src/instance/element-unit-cache") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py index 166632e8..26824dba 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/unit_testing.py @@ -17,6 +17,9 @@ SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS = None SPIFFWORKFLOW_BACKEND_LOG_LEVEL = environ.get("SPIFFWORKFLOW_BACKEND_LOG_LEVEL", default="debug") SPIFFWORKFLOW_BACKEND_GIT_COMMIT_ON_SAVE = False +SPIFFWORKFLOW_BACKEND_WEBHOOK_PROCESS_MODEL_IDENTIFIER = "test_group/simple_script" +SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET = "test_github_webhook_secret" # noqa: S105 + # NOTE: set this here since nox shoves tests and src code to # different places and this allows us to know exactly where we are at the start worker_id = environ.get("PYTEST_XDIST_WORKER") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index a3b3217d..9f2f7884 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -15,6 +15,7 @@ from spiffworkflow_backend.models.group import GroupModel SPIFF_NO_AUTH_USER = "spiff_no_auth_guest_user" SPIFF_GUEST_USER = "spiff_guest_user" +SPIFF_SYSTEM_USER = "spiff_system_user" SPIFF_GENERATED_JWT_KEY_ID = "spiff_backend" SPIFF_GENERATED_JWT_ALGORITHM = "HS256" SPIFF_GENERATED_JWT_AUDIENCE = "spiffworkflow-backend" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py index 2231f6d7..28266457 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py @@ -7,25 +7,16 @@ from flask import g from flask import jsonify from flask import make_response from flask.wrappers import Response -from SpiffWorkflow.util.deep_merge import DeepMerge # type: ignore from spiffworkflow_backend.exceptions.api_error import ApiError -from spiffworkflow_backend.models.process_instance import ProcessInstanceModel -from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id -from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.jinja_service import JinjaService -from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine -from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor -from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError -from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.spec_file_service import SpecFileService -from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError def extension_run( @@ -108,56 +99,17 @@ def _run_extension( persistence_level = ui_schema_action.get("persistence_level", "none") process_id_to_run = ui_schema_action.get("process_id_to_run", None) - process_instance = None - if persistence_level == "none": - process_instance = ProcessInstanceModel( - status=ProcessInstanceStatus.not_started.value, - process_initiator_id=g.user.id, - process_model_identifier=process_model.id, - process_model_display_name=process_model.display_name, - persistence_level=persistence_level, - ) - else: - process_instance = ProcessInstanceService.create_process_instance_from_process_model_identifier( - process_model_identifier, g.user - ) + data_to_inject = None + if body and "extension_input" in body: + data_to_inject = body["extension_input"] - processor = None - try: - # this is only creates new process instances so no need to worry about process instance migrations - processor = ProcessInstanceProcessor( - process_instance, - script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False), - process_id_to_run=process_id_to_run, - ) - save_to_db = process_instance.persistence_level != "none" - if body and "extension_input" in body: - processor.do_engine_steps(save=save_to_db, execution_strategy_name="run_current_ready_tasks") - next_task = processor.next_task() - DeepMerge.merge(next_task.data, body["extension_input"]) - processor.do_engine_steps(save=save_to_db, execution_strategy_name="greedy") - except ( - ApiError, - ProcessInstanceIsNotEnqueuedError, - ProcessInstanceIsAlreadyLockedError, - WorkflowExecutionServiceError, - ) as e: - ErrorHandlingService.handle_error(process_instance, e) - raise e - except Exception as e: - ErrorHandlingService.handle_error(process_instance, e) - # FIXME: this is going to point someone to the wrong task - it's misinformation for errors in sub-processes. - # we need to recurse through all last tasks if the last task is a call activity or subprocess. - if processor is not None: - task = processor.bpmn_process_instance.last_task - if task is not None: - raise ApiError.from_task( - error_code="unknown_exception", - message=f"An unknown error occurred. Original error: {e}", - status_code=400, - task=task, - ) from e - raise e + processor = ProcessInstanceService.create_and_run_process_instance( + process_model=process_model, + persistence_level=persistence_level, + data_to_inject=data_to_inject, + process_id_to_run=process_id_to_run, + user=g.user, + ) task_data = {} if processor is not None: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py index e40befed..e7bef567 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -1,4 +1,3 @@ -import json from typing import Any from uuid import UUID @@ -8,7 +7,6 @@ from flask import current_app from flask import g from flask import jsonify from flask import make_response -from flask import request from flask.wrappers import Response from sqlalchemy import and_ from sqlalchemy import or_ @@ -209,20 +207,6 @@ def process_data_file_download( ) -# sample body: -# {"ref": "refs/heads/main", "repository": {"name": "sample-process-models", -# "full_name": "sartography/sample-process-models", "private": False .... }} -# test with: ngrok http 7000 -# or with: -# npm install -g localtunnel && lt --port 7000 --subdomain oh-so-hot -# where 7000 is the port the app is running on locally -def github_webhook_receive(body: dict) -> Response: - auth_header = request.headers.get("X-Hub-Signature-256") - AuthenticationService.verify_sha256_token(auth_header) - result = GitService.handle_web_hook(body) - return Response(json.dumps({"git_pull": result}), status=200, mimetype="application/json") - - def _get_required_parameter_or_raise(parameter: str, post_body: dict[str, Any]) -> Any: return_value = None if parameter in post_body: @@ -357,3 +341,19 @@ def _find_process_instance_for_me_or_raise( process_instance.actions = {"read": {"path": target_uri, "method": "GET"}} return process_instance + + +def _get_process_model_for_instantiation( + process_model_identifier: str, +) -> ProcessModelInfo: + process_model = _get_process_model(process_model_identifier) + if process_model.primary_file_name is None: + raise ApiError( + error_code="process_model_missing_primary_bpmn_file", + message=( + f"Process Model '{process_model_identifier}' does not have a primary" + " bpmn file. One must be set in order to instantiate this model." + ), + status_code=400, + ) + return process_model diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py index 5caad59d..6b8eb212 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py @@ -40,6 +40,7 @@ from spiffworkflow_backend.models.task_definition import TaskDefinitionModel from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model +from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model_for_instantiation from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService @@ -685,7 +686,7 @@ def _process_instance_run( def _process_instance_create( process_model_identifier: str, ) -> ProcessInstanceModel: - process_model = _get_process_model(process_model_identifier) + process_model = _get_process_model_for_instantiation(process_model_identifier) if process_model.primary_file_name is None: raise ApiError( error_code="process_model_missing_primary_bpmn_file", diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/webhooks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/webhooks_controller.py new file mode 100644 index 00000000..2ccb95c1 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/webhooks_controller.py @@ -0,0 +1,59 @@ +import json + +from flask import current_app +from flask import request +from flask.wrappers import Response + +from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model_for_instantiation +from spiffworkflow_backend.routes.process_api_blueprint import _un_modify_modified_process_model_id +from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401 +from spiffworkflow_backend.services.git_service import GitService +from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService + + +# sample body: +# {"ref": "refs/heads/main", "repository": {"name": "sample-process-models", +# "full_name": "sartography/sample-process-models", "private": False .... }} +# test with: ngrok http 7000 +# or with: +# npm install -g localtunnel && lt --port 7000 --subdomain oh-so-hot +# where 7000 is the port the app is running on locally +# so this would work: curl https://oh-so-hot.loca.lt/v1.0/status +def github_webhook_receive(body: dict) -> Response: + _enforce_github_auth() + result = GitService.handle_web_hook(body) + return Response(json.dumps({"git_pull": result}), status=200, mimetype="application/json") + + +def webhook(body: dict) -> Response: + if current_app.config["SPIFFWORKFLOW_BACKEND_WEBHOOK_ENFORCES_GITHUB_AUTH"] is True: + _enforce_github_auth() + + if current_app.config["SPIFFWORKFLOW_BACKEND_WEBHOOK_PROCESS_MODEL_IDENTIFIER"] is None: + error_message = "Webhook process model implementation not configured" + raise ApiError( + error_code="webhook_not_configured", + message=error_message, + status_code=501, + ) + + process_model = _get_process_model_for_instantiation( + _un_modify_modified_process_model_id(current_app.config["SPIFFWORKFLOW_BACKEND_WEBHOOK_PROCESS_MODEL_IDENTIFIER"]) + ) + ProcessInstanceService.create_and_run_process_instance( + process_model=process_model, + persistence_level="none", + data_to_inject={"headers": dict(request.headers), "body": body}, + ) + + # ensure we commit the message instances + db.session.commit() + + return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + + +def _enforce_github_auth() -> None: + auth_header = request.headers.get("X-Hub-Signature-256") + AuthenticationService.verify_sha256_token(auth_header) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 78daa85a..810bf98d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -241,6 +241,7 @@ class AuthorizationService: "task_allows_guest", "test_raise_error", "url_info", + "webhook", ] if request.method == "OPTIONS": return True diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py index 58c90ba9..60e03607 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py @@ -13,6 +13,7 @@ from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService +from spiffworkflow_backend.services.user_service import UserService class MessageServiceError(Exception): @@ -52,9 +53,10 @@ class MessageService: message_name=message_instance_send.name ).first() if message_triggerable_process_model: - receiving_process = MessageService.start_process_with_message( - message_triggerable_process_model, message_instance_send.user - ) + user: UserModel | None = message_instance_send.user + if user is None: + user = UserService.find_or_create_system_user() + receiving_process = MessageService.start_process_with_message(message_triggerable_process_model, user) message_instance_receive = MessageInstanceModel.query.filter_by( process_instance_id=receiving_process.id, message_type="receive", diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index bba7b50b..240da1cf 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -45,13 +45,17 @@ from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitService +from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError +from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.workflow_execution_service import TaskRunnability +from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError from spiffworkflow_backend.services.workflow_service import WorkflowService from spiffworkflow_backend.specs.start_event import StartConfiguration @@ -563,5 +567,68 @@ class ProcessInstanceService: assigned_user_group_identifier=assigned_user_group_identifier, potential_owner_usernames=potential_owner_usernames, ) - return task + + @classmethod + def create_and_run_process_instance( + cls, + process_model: ProcessModelInfo, + persistence_level: str, + data_to_inject: dict | None = None, + process_id_to_run: str | None = None, + user: UserModel | None = None, + ) -> ProcessInstanceProcessor: + process_instance = None + if persistence_level == "none": + user_id = user.id if user is not None else None + process_instance = ProcessInstanceModel( + status=ProcessInstanceStatus.not_started.value, + process_initiator_id=user_id, + process_model_identifier=process_model.id, + process_model_display_name=process_model.display_name, + persistence_level=persistence_level, + ) + else: + if user is None: + raise Exception("User must be provided to create a persistent process instance") + process_instance = ProcessInstanceService.create_process_instance_from_process_model_identifier( + process_model.id, user + ) + + processor = None + try: + # this is only creates new process instances so no need to worry about process instance migrations + processor = ProcessInstanceProcessor( + process_instance, + script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False), + process_id_to_run=process_id_to_run, + ) + save_to_db = process_instance.persistence_level != "none" + if data_to_inject is not None: + processor.do_engine_steps(save=save_to_db, execution_strategy_name="run_current_ready_tasks") + next_task = processor.next_task() + DeepMerge.merge(next_task.data, data_to_inject) + processor.do_engine_steps(save=save_to_db, execution_strategy_name="greedy") + except ( + ApiError, + ProcessInstanceIsNotEnqueuedError, + ProcessInstanceIsAlreadyLockedError, + WorkflowExecutionServiceError, + ) as e: + ErrorHandlingService.handle_error(process_instance, e) + raise e + except Exception as e: + ErrorHandlingService.handle_error(process_instance, e) + # FIXME: this is going to point someone to the wrong task - it's misinformation for errors in sub-processes. + # we need to recurse through all last tasks if the last task is a call activity or subprocess. + if processor is not None: + task = processor.bpmn_process_instance.last_task + if task is not None: + raise ApiError.from_task( + error_code="unknown_exception", + message=f"An unknown error occurred. Original error: {e}", + status_code=400, + task=task, + ) from e + raise e + return processor diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py index 476f1a7c..76ef5d0f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py @@ -15,6 +15,7 @@ from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.principal import MissingPrincipalError from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.user import SPIFF_GUEST_USER +from spiffworkflow_backend.models.user import SPIFF_SYSTEM_USER from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentNotFoundError @@ -266,11 +267,21 @@ class UserService: @classmethod def find_or_create_guest_user(cls, username: str = SPIFF_GUEST_USER, group_identifier: str = SPIFF_GUEST_GROUP) -> UserModel: - guest_user: UserModel | None = UserModel.query.filter_by( + user: UserModel | None = UserModel.query.filter_by( username=username, service="spiff_guest_service", service_id="spiff_guest_service_id" ).first() - if guest_user is None: - guest_user = cls.create_user(username, "spiff_guest_service", "spiff_guest_service_id") - cls.add_user_to_group_or_add_to_waiting(guest_user.username, group_identifier) + if user is None: + user = cls.create_user(username, "spiff_guest_service", "spiff_guest_service_id") + cls.add_user_to_group_or_add_to_waiting(user.username, group_identifier) - return guest_user + return user + + @classmethod + def find_or_create_system_user(cls, username: str = SPIFF_SYSTEM_USER) -> UserModel: + user: UserModel | None = UserModel.query.filter_by( + username=username, service="spiff_system_service", service_id="spiff_system_service_id" + ).first() + if user is None: + user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id") + + return user diff --git a/spiffworkflow-backend/tests/data/simple-message-send-receive/message_start_event.bpmn b/spiffworkflow-backend/tests/data/simple-message-send-receive/message_start_event.bpmn new file mode 100644 index 00000000..b3e02410 --- /dev/null +++ b/spiffworkflow-backend/tests/data/simple-message-send-receive/message_start_event.bpmn @@ -0,0 +1,41 @@ + + + + + + + + Flow_08cv33e + + + + + Flow_08cv33e + + + + + the_message + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/simple-message-send-receive/process_model.json b/spiffworkflow-backend/tests/data/simple-message-send-receive/process_model.json new file mode 100644 index 00000000..5925b1ef --- /dev/null +++ b/spiffworkflow-backend/tests/data/simple-message-send-receive/process_model.json @@ -0,0 +1,9 @@ +{ + "description": "", + "display_name": "simple_message_send_receive", + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "metadata_extraction_paths": null, + "primary_file_name": "simple-message-send-receive.bpmn", + "primary_process_id": "Process_simple_message_send_receive_ivod1hz" +} diff --git a/spiffworkflow-backend/tests/data/simple-message-send-receive/simple-message-send-receive.bpmn b/spiffworkflow-backend/tests/data/simple-message-send-receive/simple-message-send-receive.bpmn new file mode 100644 index 00000000..9c2a80b1 --- /dev/null +++ b/spiffworkflow-backend/tests/data/simple-message-send-receive/simple-message-send-receive.bpmn @@ -0,0 +1,64 @@ + + + + + + + + Flow_17db3yp + + + + The process instance completed successfully. + + Flow_12pkbxb + + + + This is an example **Manual Task**. A **Manual Task** is designed to allow someone to complete a task outside of the system and then report back that it is complete. You can click the *Continue* button to proceed. When you are done running this process, you can edit the **Process Model** to include a: + + * **Script Task** - write a short snippet of python code to update some data + * **User Task** - generate a form that collects information from a user + * **Service Task** - communicate with an external API to fetch or update some data. + +You can also change the text you are reading here by updating the *Instructions* on this example manual task. + + Flow_17db3yp + Flow_12pkbxb + + + + + + + { + "a": 1 +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index da7e8c9f..65a46128 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -1,4 +1,3 @@ -"""Test Process Api Blueprint.""" import base64 import io import json @@ -37,8 +36,6 @@ from spiffworkflow_backend.services.user_service import UserService from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.test_data import load_test_spec -# from spiffworkflow_backend.services.git_service import GitService - class TestProcessApi(BaseTest): def test_returns_403_if_user_does_not_have_permission( diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_webhooks_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_webhooks_controller.py new file mode 100644 index 00000000..d29abb0e --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_webhooks_controller.py @@ -0,0 +1,36 @@ +import json +from hashlib import sha256 +from hmac import HMAC + +from connexion import FlaskApp # type: ignore +from flask.app import Flask +from flask.testing import FlaskClient + +from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec + + +class TestWebhooksController(BaseTest): + def test_webhook_runs_configured_process_model( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + load_test_spec( + "test_group/simple_script", + process_model_source_directory="simple_script", + ) + request_data = json.dumps({"body": "THIS IS OUR REQEST"}) + encoded_signature = self._create_encoded_signature(app, request_data) + + response = client.post( + "/v1.0/webhook", + headers={"X-Hub-Signature-256": f"sha256={encoded_signature}", "Content-type": "application/json"}, + data=request_data, + ) + assert response.status_code == 200 + + def _create_encoded_signature(self, app: FlaskApp, request_data: str) -> str: + secret = app.config["SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET"].encode() + return HMAC(key=secret, msg=request_data.encode(), digestmod=sha256).hexdigest() diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_message_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_message_service.py index e4ae8262..78853cec 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_message_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_message_service.py @@ -1,8 +1,10 @@ from flask import Flask from flask.testing import FlaskClient +from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_triggerable_process_model import MessageTriggerableProcessModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.services.message_service import MessageService from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService @@ -207,3 +209,46 @@ class TestMessageService(BaseTest): message_instances = MessageInstanceModel.query.all() assert len(message_instances) == 1 assert message_instances[0].name == "travel_start_test_v2" + + def test_can_send_a_message_with_non_persistent_process_instance( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + process_model_sender = load_test_spec( + "test_group/simple-message-send", + process_model_source_directory="simple-message-send-receive", + bpmn_file_name="simple-message-send-receive.bpmn", + ) + load_test_spec( + "test_group/simple-message-receive", + process_model_source_directory="simple-message-send-receive", + bpmn_file_name="message_start_event.bpmn", + ) + + message_triggerable_process_model = MessageTriggerableProcessModel.query.filter_by(message_name="message_one").first() + assert message_triggerable_process_model is not None + + processor = ProcessInstanceService.create_and_run_process_instance( + process_model=process_model_sender, + persistence_level="none", + ) + assert processor.process_instance_model.process_model_identifier == "test_group/simple-message-send" + + # ensure we commit the message instances + db.session.commit() + + message_instances = MessageInstanceModel.query.all() + assert len(message_instances) == 1 + + MessageService.correlate_all_message_instances() + + process_instances = ProcessInstanceModel.query.all() + assert len(process_instances) == 1 + assert process_instances[0].status == ProcessInstanceStatus.complete.value + assert process_instances[0].process_model_identifier == "test_group/simple-message-receive" + + message_instances = MessageInstanceModel.query.all() + assert len(message_instances) == 2 + mi_statuses = [mi.status for mi in message_instances] + assert mi_statuses == ["completed", "completed"]