From de0653e328f9a1bdcb66dd61886cb93b2582b14d 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. --- CHANGELOG.md | 3 +- src/backend/joanie/core/exceptions.py | 7 + src/backend/joanie/core/models/products.py | 18 +- .../joanie/signature/backends/lex_persona.py | 17 +- .../joanie/tests/core/test_models_order.py | 776 ++++++++++++++++++ .../test_delete_signing_procedure.py | 45 +- 6 files changed, 859 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 090e9eefc..16913681a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to ### Fixed +- Handle timeout and exception in `submit_for_signature` to + update the contract without the outdated references - Improve signature backend `handle_notification` error catching - Allow to cancel an enrollment order linked to an archived course run @@ -50,7 +52,6 @@ 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 756194bfd..3cc03e12c 100644 --- a/src/backend/joanie/core/exceptions.py +++ b/src/backend/joanie/core/exceptions.py @@ -29,3 +29,10 @@ class CertificateGenerationError(Exception): Exception raised when the certificate generation process fails due to the order not meeting all specified conditions. """ + + +class BackendTimeout(Exception): + """ + 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 93631b30c..fceaaa09b 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 @@ -38,6 +38,7 @@ from joanie.core.utils import issuers, webhooks from joanie.core.utils.payment_schedule import generate as generate_payment_schedule from joanie.signature.backends import get_signature_backend +from joanie.signature.exceptions import DeleteSignatureProcedureFailed logger = logging.getLogger(__name__) @@ -965,9 +966,18 @@ def submit_for_signature(self, user: User): ) if should_be_resubmitted: - backend_signature.delete_signing_procedure( - contract.signature_backend_reference - ) + # The signature provider may delay confirming deletion. If timeout occurs, reset + # submission values, as the signature provider will delete them. In an edge case, the + # reference `contract.signature_backend_reference` cannot be used and may already + # be deleted causing a 'not found' error from the signature provider. + try: + backend_signature.delete_signing_procedure( + contract.signature_backend_reference + ) + except (BackendTimeout, DeleteSignatureProcedureFailed): + pass + contract.reset_submission_for_signature() + 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/signature/backends/lex_persona.py b/src/backend/joanie/signature/backends/lex_persona.py index f4bbae81e..143f6695c 100644 --- a/src/backend/joanie/signature/backends/lex_persona.py +++ b/src/backend/joanie/signature/backends/lex_persona.py @@ -9,10 +9,12 @@ from django.core.exceptions import ValidationError import requests +from requests.exceptions import ReadTimeout from rest_framework.request import Request from sentry_sdk import capture_exception from joanie.core import enums, models +from joanie.core.exceptions import BackendTimeout from joanie.core.utils.contract import order_has_organization_owner from joanie.signature import exceptions from joanie.signature.backends.base import BaseSignatureBackend @@ -528,7 +530,20 @@ def delete_signing_procedure(self, reference_id: str): url = f"{base_url}/api/workflows/{reference_id}" headers = {"Authorization": f"Bearer {token}"} - response = requests.delete(url, headers=headers, timeout=timeout) + try: + response = requests.delete(url, headers=headers, timeout=timeout) + except ReadTimeout as exception: + logger.error( + exception, + extra={ + "context": { + "signature_backend_reference": reference_id, + } + }, + ) + raise BackendTimeout( + f"Deletion request is taking longer than expected for reference: {reference_id}" + ) from exception if not response.ok: logger.error( diff --git a/src/backend/joanie/tests/core/test_models_order.py b/src/backend/joanie/tests/core/test_models_order.py index f5afc7323..23827fcc1 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 requests.exceptions import ReadTimeout + from joanie.core import enums, factories 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,774 @@ 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 + # when the second call of `submit_for_signature` occurs + responses.add( + responses.DELETE, + f"https://lex_persona.test01.com/api/workflows/{workflow_id}", + body=ReadTimeout( + f"Deletion request is taking longer than expected for reference: {workflow_id}", + ), + ) + # 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 in the logger message the reference wfl_id_fake_1 being deleted + self.assertLogsEquals( + logger.records, + [ + ( + "ERROR", + f"Deletion request is taking longer than expected for reference: {workflow_id}", + { + "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 in the logger message the reference wfl_id_fake_1 trying to deleted + # but fails + self.assertLogsEquals( + logger.records, + [ + ( + "ERROR", + "Lex Persona: Unable to delete the signature procedure" + f" the reference does not exist {workflow_id}, reason: {error_response}", + ), + ], + ) + + # Our contract must have the new values of the document to sign + 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) diff --git a/src/backend/joanie/tests/signature/backends/lex_persona/test_delete_signing_procedure.py b/src/backend/joanie/tests/signature/backends/lex_persona/test_delete_signing_procedure.py index 651e334fc..c929f2db3 100644 --- a/src/backend/joanie/tests/signature/backends/lex_persona/test_delete_signing_procedure.py +++ b/src/backend/joanie/tests/signature/backends/lex_persona/test_delete_signing_procedure.py @@ -6,9 +6,12 @@ from django.test.utils import override_settings import responses +from requests.exceptions import ReadTimeout +from joanie.core.exceptions import BackendTimeout from joanie.signature import exceptions from joanie.signature.backends import get_signature_backend +from joanie.tests.base import BaseLogMixinTestCase from . import get_expected_workflow_payload @@ -23,7 +26,7 @@ JOANIE_SIGNATURE_VALIDITY_PERIOD_IN_SECONDS=60 * 60 * 24 * 15, JOANIE_SIGNATURE_TIMEOUT=3, ) -class LexPersonaBackendTestCase(TestCase): +class LexPersonaBackendTestCase(TestCase, BaseLogMixinTestCase): """Test suite for Lex Persona Signature provider Backend delete_signing_procedure.""" @responses.activate @@ -92,3 +95,43 @@ def test_backend_lex_persona_delete_signing_procedure_failed(self): responses.calls[0].request.headers["Authorization"], "Bearer token_id_fake" ) self.assertEqual(responses.calls[0].request.method, "DELETE") + + @responses.activate + def test_backend_lex_persona_delete_signing_procedure_reaches_timeout(self): + """ + When deleting a signature procedure requests reaches a `ReadTimeout`, it should raise + a `BackendTimeOut` with the error message mentioning that it takes a while for the + signature provider to delete the reference on their side. We should also see log messages + that mentions the reference being deleted. + """ + backend = get_signature_backend() + + responses.add( + responses.DELETE, + "https://lex_persona.test01.com/api/workflows/wfl_id_fake", + body=ReadTimeout( + "Deletion request is taking longer than expected for reference: wfl_id_fake" + ), + ) + + with self.assertRaises(BackendTimeout) as context: + with self.assertLogs("joanie") as logger: + backend.delete_signing_procedure(reference_id="wfl_id_fake") + + self.assertEqual( + str(context.exception), + "Deletion request is taking longer than expected for reference: wfl_id_fake", + ) + + self.assertLogsEquals( + logger.records, + [ + ( + "ERROR", + "Deletion request is taking longer than expected for reference: wfl_id_fake", + { + "signature_backend_reference": str, + }, + ) + ], + )