diff --git a/CHANGELOG.md b/CHANGELOG.md index a78408a39e..5bc2c46794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to ### Fixed +- Handle timeout and exception in `submit_for_signature` to + update the contract without the outdated references - Allow to cancel an enrollment order linked to an archived course run ## [2.6.1] - 2024-07-25 @@ -44,7 +46,7 @@ and this project adheres to - Do not update OpenEdX enrollment if this one is already up-to-date on the remote lms -- +- ## [2.4.0] - 2024-06-21 diff --git a/src/backend/joanie/core/exceptions.py b/src/backend/joanie/core/exceptions.py index 756194bfd0..80350014c6 100644 --- a/src/backend/joanie/core/exceptions.py +++ b/src/backend/joanie/core/exceptions.py @@ -2,6 +2,8 @@ Specific exceptions for the core application """ +from requests.exceptions import ReadTimeout + class EnrollmentError(Exception): """An exception to raise if an enrollment fails.""" @@ -29,3 +31,10 @@ class CertificateGenerationError(Exception): Exception raised when the certificate generation process fails due to the order not meeting all specified conditions. """ + + +class BackendTimeOut(ReadTimeout): + """ + Exception raised when a backend reaches the timeout set when we are waiting + for the response. + """ diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index 93631b30c3..05668d6fe1 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -18,7 +18,7 @@ from urllib3.util import Retry from joanie.core import enums -from joanie.core.exceptions import CertificateGenerationError +from joanie.core.exceptions import BackendTimeOut, CertificateGenerationError from joanie.core.fields.schedule import OrderPaymentScheduleEncoder from joanie.core.flows.order import OrderFlow from joanie.core.models.accounts import User @@ -37,7 +37,9 @@ from joanie.core.utils import contract_definition as contract_definition_utility from joanie.core.utils import issuers, webhooks from joanie.core.utils.payment_schedule import generate as generate_payment_schedule +from joanie.core.utils.signature import handle_signature_deletion_error from joanie.signature.backends import get_signature_backend +from joanie.signature.exceptions import DeleteSignatureProcedureFailed logger = logging.getLogger(__name__) @@ -965,9 +967,25 @@ def submit_for_signature(self, user: User): ) if should_be_resubmitted: - backend_signature.delete_signing_procedure( - contract.signature_backend_reference - ) + # The signature provider may take some time to respond with the confirmation of the + # deletion. If we reach the timeout limit, we should reset the contract submission + # values because the signature provider will delete them. + # We won't be able to use the `contract.signature_backend_reference` again. + # There can be edge case where the signature backend reference was already deleted, + # causing an error that the reference was not found at the signature provider side. + try: + backend_signature.delete_signing_procedure( + contract.signature_backend_reference + ) + except BackendTimeOut as exception: # pylint: disable=unused-variable + handle_signature_deletion_error( + order=self, error_message="Timeout on signature reference deletion" + ) + except DeleteSignatureProcedureFailed as exception: # pylint: disable=unused-variable + handle_signature_deletion_error( + order=self, error_message="Failed to delete signature procedure" + ) + was_already_submitted = False # We want to submit or re-submit the contract for signature in three cases: # 1- the contract was never submitted for signature before diff --git a/src/backend/joanie/core/utils/signature.py b/src/backend/joanie/core/utils/signature.py index dba553fd47..e8469684b6 100644 --- a/src/backend/joanie/core/utils/signature.py +++ b/src/backend/joanie/core/utils/signature.py @@ -4,11 +4,14 @@ import hashlib import hmac +import logging from django.conf import settings from rest_framework import exceptions +logger = logging.getLogger(__name__) + def check_signature(request, settings_name): """Check the signature of a request.""" @@ -33,3 +36,24 @@ def check_signature(request, settings_name): ) if not signature_is_valid: raise exceptions.AuthenticationFailed("Invalid authentication.") + + +def handle_signature_deletion_error(order, error_message): + """ + Handles the exception raised on the deleting of signing procedure. + It's responsible to reset the submission values from the outdated + contract of the order. + """ + logger.error( + error_message, + extra={ + "context": { + "order": order.to_dict(), + "product": order.product.to_dict(), + "signature_backend_reference": ( + order.contract.signature_backend_reference + ), + } + }, + ) + order.contract.reset_submission_for_signature() diff --git a/src/backend/joanie/tests/core/test_models_order.py b/src/backend/joanie/tests/core/test_models_order.py index f5afc73238..2e5dccd7d8 100644 --- a/src/backend/joanie/tests/core/test_models_order.py +++ b/src/backend/joanie/tests/core/test_models_order.py @@ -7,6 +7,7 @@ import random from datetime import datetime, timedelta, timezone from decimal import Decimal +from http import HTTPStatus from unittest import mock from django.contrib.sites.models import Site @@ -16,11 +17,15 @@ from django.test.utils import override_settings from django.utils import timezone as django_timezone +import responses + from joanie.core import enums, factories +from joanie.core.exceptions import BackendTimeOut from joanie.core.models import Contract, CourseState from joanie.core.utils import contract_definition from joanie.payment.factories import BillingAddressDictFactory, InvoiceFactory from joanie.tests.base import BaseLogMixinTestCase +from joanie.tests.signature.backends.lex_persona import get_expected_workflow_payload class OrderModelsTestCase(TestCase, BaseLogMixinTestCase): @@ -1068,3 +1073,781 @@ def test_api_order_allow_to_cancel_with_archived_course_run(self): order.flow.cancel() self.assertEqual(order.state, enums.ORDER_STATE_CANCELED) + + # pylint: disable=too-many-locals,unexpected-keyword-arg,no-value-for-parameter + @override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.lex_persona.LexPersonaBackend", + JOANIE_SIGNATURE_LEXPERSONA_BASE_URL="https://lex_persona.test01.com", + JOANIE_SIGNATURE_LEXPERSONA_CONSENT_PAGE_ID="cop_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_SESSION_USER_ID="usr_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_PROFILE_ID="sip_profile_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_TOKEN="token_id_fake", + JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, + JOANIE_SIGNATURE_TIMEOUT=3, + ) + @responses.activate(assert_all_requests_are_fired=True) + def test_models_order_submit_for_signature_step_delete_signing_procedure_timeout( + self, + ): + """ + We test the behavior of `submit_for_signature` when a `ReadTimeout` error is caught + during the `delete_signing_procedure` API call. In this specific situation it should + raise the exception `BackendTimeout`. Here, we simulate the case where the signature + provider takes a long time to process the deletion of a signature workflow on their side. + For this test, we have prepared, using responses, all the requests that will occur during + the two calls to `submit_for_signature` for an order. + + When the condition for `should_be_resubmitted` is met, we simulate a `BackendTimeout` + during the `delete_signing_procedure` API call. The contract should then be reset before + submitting the new document for signature. + + The first reference will have the value `wfl_id_fake_1`, and the second will have the value + `wfl_id_fake_2`. At the end of this test, our contract should have the value + `wfl_id_fake_2`, with the updated hash value. Additionally, we should see the title + change in the contract's definition (which triggered the new contract submission). + """ + user = factories.UserFactory( + email="johnnydo@example.fr", + first_name="Johnny", + last_name=".", + language="fr-fr", + ) + order = factories.OrderFactory( + owner=user, + state=enums.ORDER_STATE_VALIDATED, + product__contract_definition=factories.ContractDefinitionFactory( + title="Contract grade 1", + name=enums.CONTRACT_NAME_CHOICES[0][0], + description="Contract Definition", + ), + ) + factories.UserOrganizationAccessFactory.create_batch( + 3, organization=order.organization, role="owner" + ) + workflow_id = "wfl_id_fake_1" + hash_1 = "wpTD3tstfdt9XfuFK+sv4/y6fv3lx3hwZ2gjQ2DBrxs=" + # Create workflow for the first document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/users/usr_id_fake/workflows", + status=HTTPStatus.OK, + json={ + "created": 1696238245608, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "Contract Definition", + "email": order.owner.email, + "firstName": order.owner.first_name, + "groupId": "grp_id_fake", + "id": workflow_id, + "lastName": ".", + "logs": [], + "name": "Contract Definition", + "notifiedEvents": [ + "recipientRefused", + "recipientFinished", + "workflowFinished", + ], + "progress": 0, + "steps": [ + { + "allowComments": True, + "hideAttachments": False, + "hideWorkflowRecipients": True, + "id": "stp_id_fake", + "invitePeriod": None, + "isFinished": False, + "isStarted": False, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_id_fake", + "country": order.main_invoice.recipient_address.country.code.upper(), # pylint: disable=line-too-long + "email": "johnnydoe@example.fr", + "firstName": "Johnny", + "lastName": ".", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 1296000000, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238245608, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "stopped", + }, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + ], + ) + # Upload the file to workflow for the first document to sign on the contract + upload_file_api_url = ( + f"https://lex_persona.test01.com/api/workflows/{workflow_id}" + "/parts?createDocuments=true&ignoreAttachments=false" + "&signatureProfileId=sip_profile_id_fake&unzip=false&pdf2pdfa=auto" + ) + responses.add( + responses.POST, + upload_file_api_url, + status=HTTPStatus.OK, + json={ + "documents": [ + { + "created": 1696238255558, + "groupId": "grp_id_fake", + "id": "doc_id_fake", + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_1, + "size": 123616, + } + ], + "signatureProfileId": "sip_profile_id_fake", + "tenantId": "ten_id_fake", + "updated": 1696238255558, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "workflowId": "wfl_id_fake_1", + "workflowName": "Heavy Duty Wool Watch", + } + ], + "ignoredAttachments": 0, + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_1, + "size": 123616, + } + ], + }, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + ], + ) + ## Start signing procedure of the workflow + start_procedure_api_url = ( + f"https://lex_persona.test01.com/api/workflows/{workflow_id}" + ) + start_procedure_response_data = get_expected_workflow_payload("started") + responses.add( + responses.PATCH, + start_procedure_api_url, + status=HTTPStatus.OK, + json=start_procedure_response_data, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + { + "workflowStatus": "started", + } + ), + ], + ) + # Sign specific contract + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/requests/", + json={ + "consentPageId": "cop_id_fake", + "consentPageUrl": ( + "https://lex_persona.test01.com/?" + "requestToken=eyJhbGciOiJIUzI1NiJ9#requestId=req_8KVKj7qNKNDgsN7Txx1sdvaT" + ), + "created": 1696238302063, + "id": "req_id_fake", + "steps": [ + { + "allowComments": True, + "stepId": "stp_id_fake", + "workflowId": workflow_id, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238302063, + }, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer jwt_token", + }, + ), + responses.matchers.json_params_matcher( + { + "workflows": [workflow_id], + }, + ), + ], + ) + # Get the invitation link for the first document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/workflows/wfl_id_fake_1/invite", + json={"inviteUrl": "https://example.com/invite?token=jwt_token"}, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + {"recipientEmail": "johnnydo@example.fr"} + ), + ], + ) + + # Get the invitation signature for the first document to sign + invitation_url = order.submit_for_signature(user=user) + + self.assertEqual( + invitation_url, + "https://lex_persona.test01.com/?requestToken=eyJhbGciOiJIUzI1NiJ9" + "#requestId=req_8KVKj7qNKNDgsN7Txx1sdvaT", + ) + self.assertEqual(order.contract.definition.title, "Contract grade 1") + self.assertEqual(order.contract.signature_backend_reference, "wfl_id_fake_1") + self.assertEqual(order.contract.definition_checksum, hash_1) + + # Save the timestamp of the `updated_on` of the contract + contract_last_update_on_1 = order.contract.updated_on + # Change the contract definition title to trigger the `should_be_resubmitted` condition + order.product.contract_definition.title = "You know nothing John Snow." + order.product.contract_definition.save() + # Prepare the ReadTimeout on the `delete_signing_procedure` method + responses.add( + responses.DELETE, + f"https://lex_persona.test01.com/api/workflows/{workflow_id}", + body=BackendTimeOut(), + ) + # Prepare the data for the new document to sign on the contract + new_workflow_id = "wfl_id_fake_2" + hash_2 = "wpTD3tstfdt9XfuFK+sv4/y6fv3lx3hwZ2gjQ2Dqsdxs=" + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/users/usr_id_fake/workflows", + status=HTTPStatus.OK, + json={ + "created": 1696238245608, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "Contract Definition", + "email": order.owner.email, + "firstName": order.owner.first_name, + "groupId": "grp_id_fake", + "id": new_workflow_id, + "lastName": ".", + "logs": [], + "name": "Contract Definition", + "notifiedEvents": [ + "recipientRefused", + "recipientFinished", + "workflowFinished", + ], + "progress": 0, + "steps": [ + { + "allowComments": True, + "hideAttachments": False, + "hideWorkflowRecipients": True, + "id": "stp_id_fake", + "invitePeriod": None, + "isFinished": False, + "isStarted": False, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_id_fake", + "country": order.main_invoice.recipient_address.country.code.upper(), # pylint: disable=line-too-long + "email": "johnnydoe@example.fr", + "firstName": "Johnny", + "lastName": ".", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 1296000000, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238245608, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "stopped", + }, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Bearer token_id_fake"} + ) + ], + ) + # Upload the document to sign of the contract + responses.add( + responses.POST, + f"https://lex_persona.test01.com/api/workflows/{new_workflow_id}/parts", + status=HTTPStatus.OK, + json={ + "documents": [ + { + "created": 1696238255558, + "groupId": "grp_id_fake", + "id": "doc_id_fake", + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_2, + "size": 123616, + } + ], + "signatureProfileId": "sip_profile_id_fake", + "tenantId": "ten_id_fake", + "updated": 1696238255558, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "workflowId": new_workflow_id, + "workflowName": "Heavy Duty Wool Watch", + } + ], + "ignoredAttachments": 0, + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_2, + "size": 123616, + } + ], + }, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Bearer token_id_fake"} + ) + ], + ) + start_procedure_response_data = get_expected_workflow_payload("started") + start_procedure_response_data["id"] = new_workflow_id + responses.add( + responses.PATCH, + f"https://lex_persona.test01.com/api/workflows/{new_workflow_id}", + status=HTTPStatus.OK, + json=start_procedure_response_data, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + { + "workflowStatus": "started", + } + ), + ], + ) + # Sign specific contract for the new document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/requests/", + json={ + "consentPageId": "cop_id_fake", + "consentPageUrl": ( + "https://lex_persona.test01.com/?" + "requestToken=eyJhbGciOiJIUzI1NiJ9#requestId=req_8KVKj7qNKNDgsN7Txx1sdvaT" + ), + "created": 1696238302063, + "id": "req_id_fake", + "steps": [ + { + "allowComments": True, + "stepId": "stp_id_fake", + "workflowId": new_workflow_id, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238302063, + }, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer jwt_token", + }, + ), + responses.matchers.json_params_matcher( + { + "workflows": [new_workflow_id], + } + ), + ], + ) + # Invite to sign url for the new document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/workflows/wfl_id_fake_2/invite", + json={"inviteUrl": "https://example.com/invite?token=jwt_token"}, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + {"recipientEmail": "johnnydo@example.fr"} + ), + ], + ) + + # Get the invitation signature for the new document to sign + with self.assertLogs("joanie") as logger: + order.submit_for_signature(user=user) + + # We should find the in the logger message the reference wfl_id_fake_1 being deleted + self.assertLogsEquals( + logger.records, + [ + ( + "ERROR", + "Timeout on signature reference deletion", + { + "order": dict, + "product": dict, + "signature_backend_reference": str, + }, + ) + ], + ) + + # Check we have the latest data from db for the contract + contract = order.contract + contract.refresh_from_db() + contract_last_update_on_2 = contract.updated_on + + self.assertNotEqual(contract_last_update_on_1, contract_last_update_on_2) + self.assertIsNotNone(contract.submitted_for_signature_on) + self.assertEqual(contract.signature_backend_reference, new_workflow_id) + self.assertEqual(contract.definition.title, "You know nothing John Snow.") + self.assertEqual(contract.definition_checksum, hash_2) + + # pylint: disable=too-many-locals,unexpected-keyword-arg,no-value-for-parameter + @override_settings( + JOANIE_SIGNATURE_BACKEND="joanie.signature.backends.lex_persona.LexPersonaBackend", + JOANIE_SIGNATURE_LEXPERSONA_BASE_URL="https://lex_persona.test01.com", + JOANIE_SIGNATURE_LEXPERSONA_CONSENT_PAGE_ID="cop_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_SESSION_USER_ID="usr_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_PROFILE_ID="sip_profile_id_fake", + JOANIE_SIGNATURE_LEXPERSONA_TOKEN="token_id_fake", + JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, + JOANIE_SIGNATURE_TIMEOUT=3, + ) + @responses.activate(assert_all_requests_are_fired=True) + def test_models_order_submit_for_signature_step_delete_signing_procedure_reference_not_found( + self, + ): + """ + We test the behavior of `submit_for_signature` we get in return from the signature + provider the error `WorkflowNotFound` from the signature provider during the + `delete_signing_procedure` API call. It appears that when the timeout error happens, + we end up having an outdated value for the signatur_backend_reference on the contract. + When the method `submit_for_signature` is called and it attempts to delete the outdated + reference that has already been deleted, we end up with the error `WorkFlowNotFound`. + To avoid this error, we have decided to reset the contract object if it has + outdated references. + """ + user = factories.UserFactory( + email="johnnydo@example.fr", + first_name="Johnny", + last_name=".", + language="fr-fr", + ) + workflow_id = "wfl_id_fake_1" + hash_1 = "wpTD3tstfdt9XfuFK+sv4/y6fv3lx3hwZ2gjQ2DBrxs=" + order = factories.OrderFactory( + owner=user, + state=enums.ORDER_STATE_VALIDATED, + product__contract_definition=factories.ContractDefinitionFactory( + title="Contract grade 1", + name=enums.CONTRACT_NAME_CHOICES[0][0], + description="Contract Definition", + ), + ) + contract = factories.ContractFactory( + order=order, + definition=order.product.contract_definition, + signature_backend_reference=workflow_id, + definition_checksum=hash_1, + submitted_for_signature_on=django_timezone.now(), + context="context", + student_signed_on=None, + organization_signed_on=None, + ) + factories.UserOrganizationAccessFactory.create_batch( + 3, organization=order.organization, role="owner" + ) + # Prepare the `WorkFlowNotFound` error on the `delete_signing_procedure` method + error_response = { + "status": 404, + "error": "Not Found", + "message": "The specified workflow can not be found.", + "requestId": "f009", + "code": "WorkflowNotFound", + "logId": "log_fake_id", + } + responses.add( + responses.DELETE, + f"https://lex_persona.test01.com/api/workflows/{workflow_id}", + status=HTTPStatus.NOT_FOUND, + json=error_response, + ) + + # Prepare the data for the new document to sign on the contract + # Change the contract definition title to trigger the `should_be_resubmitted` condition + order.product.contract_definition.title = "You know nothing John Snow." + order.product.contract_definition.save() + + new_workflow_id = "wfl_id_fake_2" + hash_2 = "wpTD3tstfdt9XfuFK+sv4/y6fv3lx3hwZ2gjQ2Dqsdxs=" + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/users/usr_id_fake/workflows", + status=HTTPStatus.OK, + json={ + "created": 1696238245608, + "currentRecipientEmails": [], + "currentRecipientUsers": [], + "description": "Contract Definition", + "email": order.owner.email, + "firstName": order.owner.first_name, + "groupId": "grp_id_fake", + "id": new_workflow_id, + "lastName": ".", + "logs": [], + "name": "Contract Definition", + "notifiedEvents": [ + "recipientRefused", + "recipientFinished", + "workflowFinished", + ], + "progress": 0, + "steps": [ + { + "allowComments": True, + "hideAttachments": False, + "hideWorkflowRecipients": True, + "id": "stp_id_fake", + "invitePeriod": None, + "isFinished": False, + "isStarted": False, + "logs": [], + "maxInvites": 0, + "recipients": [ + { + "consentPageId": "cop_id_fake", + "country": order.main_invoice.recipient_address.country.code.upper(), # pylint: disable=line-too-long + "email": "johnnydoe@example.fr", + "firstName": "Johnny", + "lastName": ".", + "preferredLocale": "fr", + } + ], + "requiredRecipients": 1, + "sendDownloadLink": True, + "stepType": "signature", + "validityPeriod": 1296000000, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238245608, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "watchers": [], + "workflowStatus": "stopped", + }, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Bearer token_id_fake"} + ) + ], + ) + # Upload the document to sign of the contract + responses.add( + responses.POST, + f"https://lex_persona.test01.com/api/workflows/{new_workflow_id}/parts", + status=HTTPStatus.OK, + json={ + "documents": [ + { + "created": 1696238255558, + "groupId": "grp_id_fake", + "id": "doc_id_fake", + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_2, + "size": 123616, + } + ], + "signatureProfileId": "sip_profile_id_fake", + "tenantId": "ten_id_fake", + "updated": 1696238255558, + "userId": "usr_id_fake", + "viewAuthorizedGroups": ["grp_id_fake"], + "viewAuthorizedUsers": [], + "workflowId": new_workflow_id, + "workflowName": "Heavy Duty Wool Watch", + } + ], + "ignoredAttachments": 0, + "parts": [ + { + "contentType": "application/pdf", + "filename": "contract_definition.pdf", + "hash": hash_2, + "size": 123616, + } + ], + }, + match=[ + responses.matchers.header_matcher( + {"Authorization": "Bearer token_id_fake"} + ) + ], + ) + start_procedure_response_data = get_expected_workflow_payload("started") + start_procedure_response_data["id"] = new_workflow_id + responses.add( + responses.PATCH, + f"https://lex_persona.test01.com/api/workflows/{new_workflow_id}", + status=HTTPStatus.OK, + json=start_procedure_response_data, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + { + "workflowStatus": "started", + } + ), + ], + ) + # Sign specific contract for the new document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/requests/", + json={ + "consentPageId": "cop_id_fake", + "consentPageUrl": ( + "https://lex_persona.test01.com/?" + "requestToken=eyJhbGciOiJIUzI1NiJ9#requestId=req_8KVKj7qNKNDgsN7Txx1sdvaT" + ), + "created": 1696238302063, + "id": "req_id_fake", + "steps": [ + { + "allowComments": True, + "stepId": "stp_id_fake", + "workflowId": new_workflow_id, + } + ], + "tenantId": "ten_id_fake", + "updated": 1696238302063, + }, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer jwt_token", + }, + ), + responses.matchers.json_params_matcher( + { + "workflows": [new_workflow_id], + } + ), + ], + ) + # Invite to sign url for the new document to sign + responses.add( + responses.POST, + "https://lex_persona.test01.com/api/workflows/wfl_id_fake_2/invite", + json={"inviteUrl": "https://example.com/invite?token=jwt_token"}, + status=HTTPStatus.OK, + match=[ + responses.matchers.header_matcher( + { + "Authorization": "Bearer token_id_fake", + }, + ), + responses.matchers.json_params_matcher( + {"recipientEmail": "johnnydo@example.fr"} + ), + ], + ) + + # Get the invitation signature for the new document to sign + with self.assertLogs("joanie") as logger: + order.submit_for_signature(user=user) + + # We should find the in the logger message the reference wfl_id_fake_1 being deleted + self.assertLogsEquals( + logger.records, + [ + ( + "ERROR", + "Lex Persona: Unable to delete the signature procedure" + f" the reference does not exist {workflow_id}, reason: {error_response}", + ), + ( + "ERROR", + "Failed to delete signature procedure", + { + "order": dict, + "product": dict, + "signature_backend_reference": str, + }, + ), + ], + ) + + # Check we have the latest data from db for the contract + contract.refresh_from_db() + self.assertIsNotNone(contract.submitted_for_signature_on) + self.assertEqual(contract.signature_backend_reference, new_workflow_id) + self.assertEqual(contract.definition.title, "You know nothing John Snow.") + self.assertEqual(contract.definition_checksum, hash_2)