From 4eaed889899ad895a26f55fc4469a3a3b9ceee69 Mon Sep 17 00:00:00 2001 From: Jonathan Reveille Date: Fri, 13 Sep 2024 15:33:53 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(backend)=20submit=20for=20signatur?= =?UTF-8?q?e=20handle=20timeout=20delete=20signing=20procedure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the method `submit_for_signature` needs to delete a signing procedure because some elements have changed overtime in the contract definition and before the student signed the document, it appears that it may take a while on the signature provider side to execute the deletion. In order to handle and update our contract, we have added a try except block to catch the timeout issue, and proceed to update the contract as if we would have received the response from the signature provider. Before we added the fix, the contract would keep the old reference that would not work when the student wanted to sign the document, causing another error because it has already been deleted after the timeout we have set in the signature backend. In some cases, due to the timeout error, when we attempt to delete the outdated reference, we would then have a NOT_FOUND status code, we should also reset the contrat submission value as well to restart with a new contrat to submit. --- src/backend/joanie/core/exceptions.py | 9 + src/backend/joanie/core/models/products.py | 26 +- src/backend/joanie/core/utils/signature.py | 24 + .../joanie/tests/core/test_models_order.py | 783 ++++++++++++++++++ 4 files changed, 838 insertions(+), 4 deletions(-) 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)