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, + }, + ) + ], + )