mirror of
				https://github.com/juspay/hyperswitch.git
				synced 2025-10-31 10:06:32 +08:00 
			
		
		
		
	fix(connector): [Worldpay] handle multiple ddc submission for CompleteAuthorize (#8741)
This commit is contained in:
		| @ -798,7 +798,16 @@ impl ConnectorIntegration<CompleteAuthorize, CompleteAuthorizeData, PaymentsResp | |||||||
|             .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?; |             .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?; | ||||||
|         let stage = match req.status { |         let stage = match req.status { | ||||||
|             enums::AttemptStatus::DeviceDataCollectionPending => "3dsDeviceData".to_string(), |             enums::AttemptStatus::DeviceDataCollectionPending => "3dsDeviceData".to_string(), | ||||||
|             _ => "3dsChallenges".to_string(), |             enums::AttemptStatus::AuthenticationPending => "3dsChallenges".to_string(), | ||||||
|  |             _ => { | ||||||
|  |                 return Err( | ||||||
|  |                     errors::ConnectorError::RequestEncodingFailedWithReason(format!( | ||||||
|  |                         "Invalid payment status for complete authorize: {:?}", | ||||||
|  |                         req.status | ||||||
|  |                     )) | ||||||
|  |                     .into(), | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|         }; |         }; | ||||||
|         Ok(format!( |         Ok(format!( | ||||||
|             "{}api/payments/{connector_payment_id}/{stage}", |             "{}api/payments/{connector_payment_id}/{stage}", | ||||||
|  | |||||||
| @ -883,7 +883,29 @@ impl TryFrom<&types::PaymentsCompleteAuthorizeRouterData> for WorldpayCompleteAu | |||||||
|             .as_ref() |             .as_ref() | ||||||
|             .and_then(|redirect_response| redirect_response.params.as_ref()) |             .and_then(|redirect_response| redirect_response.params.as_ref()) | ||||||
|             .ok_or(errors::ConnectorError::ResponseDeserializationFailed)?; |             .ok_or(errors::ConnectorError::ResponseDeserializationFailed)?; | ||||||
|         serde_urlencoded::from_str::<Self>(params.peek()) |  | ||||||
|             .change_context(errors::ConnectorError::ResponseDeserializationFailed) |         let parsed_request = serde_urlencoded::from_str::<Self>(params.peek()) | ||||||
|  |             .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; | ||||||
|  |  | ||||||
|  |         match item.status { | ||||||
|  |             enums::AttemptStatus::DeviceDataCollectionPending => Ok(parsed_request), | ||||||
|  |             enums::AttemptStatus::AuthenticationPending => { | ||||||
|  |                 if parsed_request.collection_reference.is_some() { | ||||||
|  |                     return Err(errors::ConnectorError::InvalidDataFormat { | ||||||
|  |                         field_name: | ||||||
|  |                             "collection_reference not allowed in AuthenticationPending state", | ||||||
|  |                     } | ||||||
|  |                     .into()); | ||||||
|  |                 } | ||||||
|  |                 Ok(parsed_request) | ||||||
|  |             } | ||||||
|  |             _ => Err( | ||||||
|  |                 errors::ConnectorError::RequestEncodingFailedWithReason(format!( | ||||||
|  |                     "Invalid payment status for complete authorize: {:?}", | ||||||
|  |                     item.status | ||||||
|  |                 )) | ||||||
|  |                 .into(), | ||||||
|  |             ), | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1181,7 +1181,7 @@ pub fn build_redirection_form( | |||||||
|                     #loader1 { |                     #loader1 { | ||||||
|                         width: 500px, |                         width: 500px, | ||||||
|                     } |                     } | ||||||
|                     @media max-width: 600px { |                     @media (max-width: 600px) { | ||||||
|                         #loader1 { |                         #loader1 { | ||||||
|                             width: 200px |                             width: 200px | ||||||
|                         } |                         } | ||||||
| @ -1748,7 +1748,7 @@ pub fn build_redirection_form( | |||||||
|                                 #loader1 { |                                 #loader1 { | ||||||
|                                     width: 500px; |                                     width: 500px; | ||||||
|                                 } |                                 } | ||||||
|                                 @media max-width: 600px { |                                 @media (max-width: 600px) { | ||||||
|                                     #loader1 { |                                     #loader1 { | ||||||
|                                         width: 200px; |                                         width: 200px; | ||||||
|                                     } |                                     } | ||||||
| @ -1777,7 +1777,21 @@ pub fn build_redirection_form( | |||||||
|                     script { |                     script { | ||||||
|                         (PreEscaped(format!( |                         (PreEscaped(format!( | ||||||
|                             r#" |                             r#" | ||||||
|  |                                 var ddcProcessed = false; | ||||||
|  |                                 var timeoutHandle = null; | ||||||
|  |                                  | ||||||
|                                 function submitCollectionReference(collectionReference) {{ |                                 function submitCollectionReference(collectionReference) {{ | ||||||
|  |                                     if (ddcProcessed) {{ | ||||||
|  |                                         console.log("DDC already processed, ignoring duplicate submission"); | ||||||
|  |                                         return; | ||||||
|  |                                     }} | ||||||
|  |                                     ddcProcessed = true; | ||||||
|  |                                      | ||||||
|  |                                     if (timeoutHandle) {{ | ||||||
|  |                                         clearTimeout(timeoutHandle); | ||||||
|  |                                         timeoutHandle = null; | ||||||
|  |                                     }} | ||||||
|  |                                      | ||||||
|                                     var redirectPathname = window.location.pathname.replace(/payments\/redirect\/([^\/]+)\/([^\/]+)\/[^\/]+/, "payments/$1/$2/redirect/complete/worldpay"); |                                     var redirectPathname = window.location.pathname.replace(/payments\/redirect\/([^\/]+)\/([^\/]+)\/[^\/]+/, "payments/$1/$2/redirect/complete/worldpay"); | ||||||
|                                     var redirectUrl = window.location.origin + redirectPathname; |                                     var redirectUrl = window.location.origin + redirectPathname; | ||||||
|                                     try {{ |                                     try {{ | ||||||
| @ -1796,12 +1810,17 @@ pub fn build_redirection_form( | |||||||
|                                             window.location.replace(redirectUrl); |                                             window.location.replace(redirectUrl); | ||||||
|                                         }} |                                         }} | ||||||
|                                     }} catch (error) {{ |                                     }} catch (error) {{ | ||||||
|  |                                         console.error("Error submitting DDC:", error); | ||||||
|                                         window.location.replace(redirectUrl); |                                         window.location.replace(redirectUrl); | ||||||
|                                     }} |                                     }} | ||||||
|                                 }} |                                 }} | ||||||
|                                 var allowedHost = "{}"; |                                 var allowedHost = "{}"; | ||||||
|                                 var collectionField = "{}"; |                                 var collectionField = "{}"; | ||||||
|                                 window.addEventListener("message", function(event) {{ |                                 window.addEventListener("message", function(event) {{ | ||||||
|  |                                     if (ddcProcessed) {{ | ||||||
|  |                                         console.log("DDC already processed, ignoring message event"); | ||||||
|  |                                         return; | ||||||
|  |                                     }} | ||||||
|                                     if (event.origin === allowedHost) {{ |                                     if (event.origin === allowedHost) {{ | ||||||
|                                         try {{ |                                         try {{ | ||||||
|                                             var data = JSON.parse(event.data); |                                             var data = JSON.parse(event.data); | ||||||
| @ -1821,8 +1840,13 @@ pub fn build_redirection_form( | |||||||
|                                     submitCollectionReference(""); |                                     submitCollectionReference(""); | ||||||
|                                 }}); |                                 }}); | ||||||
|  |  | ||||||
|                                 // Redirect within 8 seconds if no collection reference is received |                                 // Timeout after 10 seconds and will submit empty collection reference | ||||||
|                                 window.setTimeout(submitCollectionReference, 8000); |                                 timeoutHandle = window.setTimeout(function() {{ | ||||||
|  |                                     if (!ddcProcessed) {{ | ||||||
|  |                                         console.log("DDC timeout reached, submitting empty collection reference"); | ||||||
|  |                                         submitCollectionReference(""); | ||||||
|  |                                     }} | ||||||
|  |                                 }}, 10000); | ||||||
|                             "#, |                             "#, | ||||||
|                             endpoint.host_str().map_or(endpoint.as_ref().split('/').take(3).collect::<Vec<&str>>().join("/"), |host| format!("{}://{}", endpoint.scheme(), host)), |                             endpoint.host_str().map_or(endpoint.as_ref().split('/').take(3).collect::<Vec<&str>>().join("/"), |host| format!("{}://{}", endpoint.scheme(), host)), | ||||||
|                             collection_id.clone().unwrap_or("".to_string()))) |                             collection_id.clone().unwrap_or("".to_string()))) | ||||||
|  | |||||||
| @ -1523,6 +1523,110 @@ export const connectorDetails = { | |||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|  |     DDCRaceConditionServerSide: getCustomExchange({ | ||||||
|  |       Request: { | ||||||
|  |         payment_method: "card", | ||||||
|  |         payment_method_type: "debit", | ||||||
|  |         payment_method_data: { | ||||||
|  |           card: successfulThreeDSTestCardDetails, | ||||||
|  |         }, | ||||||
|  |         currency: "USD", | ||||||
|  |         customer_acceptance: null, | ||||||
|  |         setup_future_usage: "on_session", | ||||||
|  |       }, | ||||||
|  |       Response: { | ||||||
|  |         status: 200, | ||||||
|  |         body: { | ||||||
|  |           status: "requires_customer_action", | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       DDCConfig: { | ||||||
|  |         completeUrlPath: "/redirect/complete/default", | ||||||
|  |         collectionReferenceParam: "collectionReference", | ||||||
|  |         firstSubmissionValue: "", | ||||||
|  |         secondSubmissionValue: "race_condition_test_ddc_123", | ||||||
|  |         expectedError: { | ||||||
|  |           status: 400, | ||||||
|  |           body: { | ||||||
|  |             error: { | ||||||
|  |               code: "IR_07", | ||||||
|  |               type: "invalid_request", | ||||||
|  |               message: | ||||||
|  |                 "Invalid value provided: collection_reference not allowed in AuthenticationPending state", | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }), | ||||||
|  |     DDCRaceConditionClientSide: getCustomExchange({ | ||||||
|  |       Request: { | ||||||
|  |         payment_method: "card", | ||||||
|  |         payment_method_type: "debit", | ||||||
|  |         payment_method_data: { | ||||||
|  |           card: successfulThreeDSTestCardDetails, | ||||||
|  |         }, | ||||||
|  |         currency: "USD", | ||||||
|  |         customer_acceptance: null, | ||||||
|  |         setup_future_usage: "on_session", | ||||||
|  |       }, | ||||||
|  |       Response: { | ||||||
|  |         status: 200, | ||||||
|  |         body: { | ||||||
|  |           status: "requires_customer_action", | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       DDCConfig: { | ||||||
|  |         redirectUrlPath: "/payments/redirect", | ||||||
|  |         collectionReferenceParam: "collectionReference", | ||||||
|  |         delayBeforeSubmission: 2000, | ||||||
|  |         raceConditionScript: ` | ||||||
|  |           <script> | ||||||
|  |             console.log("INJECTING_RACE_CONDITION_TEST"); | ||||||
|  |              | ||||||
|  |             // Track submission attempts and ddcProcessed flag behavior | ||||||
|  |             window.testResults = { | ||||||
|  |               submissionAttempts: 0, | ||||||
|  |               actualSubmissions: 0, | ||||||
|  |               blockedSubmissions: 0 | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // Override the submitCollectionReference function to test race conditions | ||||||
|  |             var originalSubmit = window.submitCollectionReference; | ||||||
|  |              | ||||||
|  |             window.submitCollectionReference = function(collectionReference) { | ||||||
|  |               window.testResults.submissionAttempts++; | ||||||
|  |               console.log("SUBMISSION_ATTEMPT_" + window.testResults.submissionAttempts + ": " + collectionReference); | ||||||
|  |                | ||||||
|  |               // Check if ddcProcessed flag would block this | ||||||
|  |               if (window.ddcProcessed) { | ||||||
|  |                 window.testResults.blockedSubmissions++; | ||||||
|  |                 console.log("SUBMISSION_BLOCKED_BY_DDC_PROCESSED_FLAG"); | ||||||
|  |                 return; | ||||||
|  |               } | ||||||
|  |                | ||||||
|  |               window.testResults.actualSubmissions++; | ||||||
|  |               console.log("SUBMISSION_PROCEEDING: " + collectionReference); | ||||||
|  |                | ||||||
|  |               if (originalSubmit) { | ||||||
|  |                 return originalSubmit(collectionReference); | ||||||
|  |               } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // Submit first value at configured timing | ||||||
|  |             setTimeout(function() { | ||||||
|  |               console.log("FIRST_SUBMISSION_TRIGGERED_AT_100MS"); | ||||||
|  |               window.submitCollectionReference(""); | ||||||
|  |             }, 100); | ||||||
|  |              | ||||||
|  |             // Submit second value at configured timing (should be blocked) | ||||||
|  |             setTimeout(function() { | ||||||
|  |               console.log("SECOND_SUBMISSION_ATTEMPTED_AT_200MS"); | ||||||
|  |               window.submitCollectionReference("test_ddc_123"); | ||||||
|  |             }, 200); | ||||||
|  |           </script> | ||||||
|  |         `, | ||||||
|  |       }, | ||||||
|  |     }), | ||||||
|   }, |   }, | ||||||
|   upi_pm: { |   upi_pm: { | ||||||
|     PaymentIntent: getCustomExchange({ |     PaymentIntent: getCustomExchange({ | ||||||
|  | |||||||
| @ -399,6 +399,7 @@ export const CONNECTOR_LISTS = { | |||||||
|       // "cybersource",    // issues with MULTIPLE_CONNECTORS handling |       // "cybersource",    // issues with MULTIPLE_CONNECTORS handling | ||||||
|       "paypal", |       "paypal", | ||||||
|     ], |     ], | ||||||
|  |     DDC_RACE_CONDITION: ["worldpay"], | ||||||
|     // Add more inclusion lists |     // Add more inclusion lists | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -45,6 +45,14 @@ const successfulThreeDsTestCardDetailsRequest = { | |||||||
|   card_cvc: "737", |   card_cvc: "737", | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const failedNoThreeDsCardDetails = { | ||||||
|  |   card_number: "4242424242424242", | ||||||
|  |   card_exp_month: "10", | ||||||
|  |   card_exp_year: "30", | ||||||
|  |   card_holder_name: "REFUSED13", | ||||||
|  |   card_cvc: "737", | ||||||
|  | }; | ||||||
|  |  | ||||||
| const paymentMethodDataNoThreeDsResponse = { | const paymentMethodDataNoThreeDsResponse = { | ||||||
|   card: { |   card: { | ||||||
|     last4: "4242", |     last4: "4242", | ||||||
| @ -119,7 +127,7 @@ export const connectorDetails = { | |||||||
|     No3DSManualCapture: { |     No3DSManualCapture: { | ||||||
|       Request: { |       Request: { | ||||||
|         payment_method: "card", |         payment_method: "card", | ||||||
|         payment_method_type: "debit", |         payment_method_type: "credit", | ||||||
|         payment_method_data: { |         payment_method_data: { | ||||||
|           card: successfulNoThreeDsCardDetailsRequest, |           card: successfulNoThreeDsCardDetailsRequest, | ||||||
|         }, |         }, | ||||||
| @ -141,7 +149,7 @@ export const connectorDetails = { | |||||||
|     No3DSAutoCapture: { |     No3DSAutoCapture: { | ||||||
|       Request: { |       Request: { | ||||||
|         payment_method: "card", |         payment_method: "card", | ||||||
|         payment_method_type: "debit", |         payment_method_type: "credit", | ||||||
|         payment_method_data: { |         payment_method_data: { | ||||||
|           card: successfulNoThreeDsCardDetailsRequest, |           card: successfulNoThreeDsCardDetailsRequest, | ||||||
|         }, |         }, | ||||||
| @ -624,6 +632,7 @@ export const connectorDetails = { | |||||||
|     ZeroAuthConfirmPayment: { |     ZeroAuthConfirmPayment: { | ||||||
|       Request: { |       Request: { | ||||||
|         payment_method: "card", |         payment_method: "card", | ||||||
|  |         payment_method_type: "debit", | ||||||
|         payment_method_data: { |         payment_method_data: { | ||||||
|           card: successfulNoThreeDsCardDetailsRequest, |           card: successfulNoThreeDsCardDetailsRequest, | ||||||
|         }, |         }, | ||||||
| @ -641,6 +650,27 @@ export const connectorDetails = { | |||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|  |     No3DSFailPayment: { | ||||||
|  |       Request: { | ||||||
|  |         payment_method: "card", | ||||||
|  |         payment_method_type: "debit", | ||||||
|  |         payment_method_data: { | ||||||
|  |           card: failedNoThreeDsCardDetails, | ||||||
|  |         }, | ||||||
|  |         customer_acceptance: null, | ||||||
|  |         setup_future_usage: "on_session", | ||||||
|  |       }, | ||||||
|  |       Response: { | ||||||
|  |         status: 200, | ||||||
|  |         body: { | ||||||
|  |           status: "failed", | ||||||
|  |           error_code: "13", | ||||||
|  |           error_message: "INVALID AMOUNT", | ||||||
|  |           unified_code: "UE_9000", | ||||||
|  |           unified_message: "Something went wrong", | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|     PaymentMethodIdMandateNo3DSAutoCapture: { |     PaymentMethodIdMandateNo3DSAutoCapture: { | ||||||
|       Request: { |       Request: { | ||||||
|         payment_method: "card", |         payment_method: "card", | ||||||
| @ -710,6 +740,116 @@ export const connectorDetails = { | |||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|  |     DDCRaceConditionServerSide: { | ||||||
|  |       ...getCustomExchange({ | ||||||
|  |         Request: { | ||||||
|  |           payment_method: "card", | ||||||
|  |           payment_method_type: "debit", | ||||||
|  |           payment_method_data: { | ||||||
|  |             card: successfulThreeDsTestCardDetailsRequest, | ||||||
|  |           }, | ||||||
|  |           currency: "USD", | ||||||
|  |           customer_acceptance: null, | ||||||
|  |           setup_future_usage: "on_session", | ||||||
|  |           browser_info, | ||||||
|  |         }, | ||||||
|  |         Response: { | ||||||
|  |           status: 200, | ||||||
|  |           body: { | ||||||
|  |             status: "requires_customer_action", | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }), | ||||||
|  |       DDCConfig: { | ||||||
|  |         completeUrlPath: "/redirect/complete/worldpay", | ||||||
|  |         collectionReferenceParam: "collectionReference", | ||||||
|  |         firstSubmissionValue: "", | ||||||
|  |         secondSubmissionValue: "race_condition_test_ddc_123", | ||||||
|  |         expectedError: { | ||||||
|  |           status: 400, | ||||||
|  |           body: { | ||||||
|  |             error: { | ||||||
|  |               code: "IR_07", | ||||||
|  |               type: "invalid_request", | ||||||
|  |               message: | ||||||
|  |                 "Invalid value provided: collection_reference not allowed in AuthenticationPending state", | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     DDCRaceConditionClientSide: { | ||||||
|  |       ...getCustomExchange({ | ||||||
|  |         Request: { | ||||||
|  |           payment_method: "card", | ||||||
|  |           payment_method_type: "debit", | ||||||
|  |           payment_method_data: { | ||||||
|  |             card: successfulThreeDsTestCardDetailsRequest, | ||||||
|  |           }, | ||||||
|  |           currency: "USD", | ||||||
|  |           customer_acceptance: null, | ||||||
|  |           setup_future_usage: "on_session", | ||||||
|  |           browser_info, | ||||||
|  |         }, | ||||||
|  |         Response: { | ||||||
|  |           status: 200, | ||||||
|  |           body: { | ||||||
|  |             status: "requires_customer_action", | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }), | ||||||
|  |       DDCConfig: { | ||||||
|  |         redirectUrlPath: "/payments/redirect", | ||||||
|  |         collectionReferenceParam: "collectionReference", | ||||||
|  |         delayBeforeSubmission: 2000, | ||||||
|  |         raceConditionScript: ` | ||||||
|  |           <script> | ||||||
|  |             console.log("INJECTING_RACE_CONDITION_TEST"); | ||||||
|  |              | ||||||
|  |             // Track submission attempts and ddcProcessed flag behavior | ||||||
|  |             window.testResults = { | ||||||
|  |               submissionAttempts: 0, | ||||||
|  |               actualSubmissions: 0, | ||||||
|  |               blockedSubmissions: 0 | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // Override the submitCollectionReference function to test race conditions | ||||||
|  |             var originalSubmit = window.submitCollectionReference; | ||||||
|  |              | ||||||
|  |             window.submitCollectionReference = function(collectionReference) { | ||||||
|  |               window.testResults.submissionAttempts++; | ||||||
|  |               console.log("SUBMISSION_ATTEMPT_" + window.testResults.submissionAttempts + ": " + collectionReference); | ||||||
|  |                | ||||||
|  |               // Check if ddcProcessed flag would block this | ||||||
|  |               if (window.ddcProcessed) { | ||||||
|  |                 window.testResults.blockedSubmissions++; | ||||||
|  |                 console.log("SUBMISSION_BLOCKED_BY_DDC_PROCESSED_FLAG"); | ||||||
|  |                 return; | ||||||
|  |               } | ||||||
|  |                | ||||||
|  |               window.testResults.actualSubmissions++; | ||||||
|  |               console.log("SUBMISSION_PROCEEDING: " + collectionReference); | ||||||
|  |                | ||||||
|  |               if (originalSubmit) { | ||||||
|  |                 return originalSubmit(collectionReference); | ||||||
|  |               } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // Submit first value at configured timing | ||||||
|  |             setTimeout(function() { | ||||||
|  |               console.log("FIRST_SUBMISSION_TRIGGERED_AT_100MS"); | ||||||
|  |               window.submitCollectionReference(""); | ||||||
|  |             }, 100); | ||||||
|  |              | ||||||
|  |             // Submit second value at configured timing (should be blocked) | ||||||
|  |             setTimeout(function() { | ||||||
|  |               console.log("SECOND_SUBMISSION_ATTEMPTED_AT_200MS"); | ||||||
|  |               window.submitCollectionReference("test_ddc_123"); | ||||||
|  |             }, 200); | ||||||
|  |           </script> | ||||||
|  |         `, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   pm_list: { |   pm_list: { | ||||||
|     PmListResponse: { |     PmListResponse: { | ||||||
|  | |||||||
| @ -0,0 +1,155 @@ | |||||||
|  | /** | ||||||
|  |  * DDC Race Condition Tests | ||||||
|  |  * | ||||||
|  |  * These tests ensure that device data collection works properly during payment authentication | ||||||
|  |  * and prevents issues when multiple requests happen at the same time. | ||||||
|  |  * | ||||||
|  |  * Server-side validation: | ||||||
|  |  * - Checks that our backend properly handles duplicate device data submissions | ||||||
|  |  * - Makes sure that once device data is collected, any additional attempts are rejected | ||||||
|  |  * | ||||||
|  |  * Client-side validation: | ||||||
|  |  * - Verifies that the payment page prevents users from accidentally submitting data twice | ||||||
|  |  * - Ensures that even if someone clicks multiple times, only one submission goes through | ||||||
|  |  * - Tests that our JavaScript protection works as expected | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import * as fixtures from "../../../fixtures/imports"; | ||||||
|  | import State from "../../../utils/State"; | ||||||
|  | import getConnectorDetails, { | ||||||
|  |   shouldIncludeConnector, | ||||||
|  |   CONNECTOR_LISTS, | ||||||
|  | } from "../../configs/Payment/Utils"; | ||||||
|  | import * as utils from "../../configs/Payment/Utils"; | ||||||
|  |  | ||||||
|  | let connector; | ||||||
|  | let globalState; | ||||||
|  |  | ||||||
|  | describe("[Payment] DDC Race Condition", () => { | ||||||
|  |   before(function () { | ||||||
|  |     let skip = false; | ||||||
|  |  | ||||||
|  |     cy.task("getGlobalState") | ||||||
|  |       .then((state) => { | ||||||
|  |         globalState = new State(state); | ||||||
|  |         connector = globalState.get("connectorId"); | ||||||
|  |  | ||||||
|  |         if ( | ||||||
|  |           shouldIncludeConnector( | ||||||
|  |             connector, | ||||||
|  |             CONNECTOR_LISTS.INCLUDE.DDC_RACE_CONDITION | ||||||
|  |           ) | ||||||
|  |         ) { | ||||||
|  |           skip = true; | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const requiredKeys = [ | ||||||
|  |           "merchantId", | ||||||
|  |           "apiKey", | ||||||
|  |           "publishableKey", | ||||||
|  |           "baseUrl", | ||||||
|  |         ]; | ||||||
|  |         const missingKeys = requiredKeys.filter((key) => !globalState.get(key)); | ||||||
|  |  | ||||||
|  |         if (missingKeys.length > 0) { | ||||||
|  |           cy.log( | ||||||
|  |             `Skipping DDC tests - missing critical state: ${missingKeys.join(", ")}` | ||||||
|  |           ); | ||||||
|  |           skip = true; | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const merchantConnectorId = globalState.get("merchantConnectorId"); | ||||||
|  |         if (!merchantConnectorId) { | ||||||
|  |           cy.log( | ||||||
|  |             "Warning: merchantConnectorId missing - may indicate connector configuration issue" | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |       .then(() => { | ||||||
|  |         if (skip) { | ||||||
|  |           this.skip(); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterEach("comprehensive cleanup", () => { | ||||||
|  |     cy.task("setGlobalState", globalState.data); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   context("[Payment] DDC Race Condition Tests", () => { | ||||||
|  |     let shouldContinue = true; | ||||||
|  |  | ||||||
|  |     beforeEach(function () { | ||||||
|  |       if (!shouldContinue) { | ||||||
|  |         this.skip(); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Only reset payment-specific state, don't clear paymentID here as it might be needed | ||||||
|  |       globalState.set("clientSecret", null); | ||||||
|  |       globalState.set("nextActionUrl", null); | ||||||
|  |  | ||||||
|  |       if (!globalState.get("customerId")) { | ||||||
|  |         cy.createCustomerCallTest(fixtures.customerCreateBody, globalState); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (!globalState.get("profileId")) { | ||||||
|  |         const defaultProfileId = globalState.get("defaultProfileId"); | ||||||
|  |         if (defaultProfileId) { | ||||||
|  |           globalState.set("profileId", defaultProfileId); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("[Payment] Server-side DDC race condition handling", () => { | ||||||
|  |       const createData = | ||||||
|  |         getConnectorDetails(connector)["card_pm"]["PaymentIntent"]; | ||||||
|  |       const confirmData = | ||||||
|  |         getConnectorDetails(connector)["card_pm"]["DDCRaceConditionServerSide"]; | ||||||
|  |  | ||||||
|  |       cy.createPaymentIntentTest( | ||||||
|  |         fixtures.createPaymentBody, | ||||||
|  |         createData, | ||||||
|  |         "three_ds", | ||||||
|  |         "automatic", | ||||||
|  |         globalState | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (shouldContinue) | ||||||
|  |         shouldContinue = utils.should_continue_further(createData); | ||||||
|  |  | ||||||
|  |       cy.confirmCallTest(fixtures.confirmBody, confirmData, true, globalState); | ||||||
|  |  | ||||||
|  |       if (shouldContinue) | ||||||
|  |         shouldContinue = utils.should_continue_further(confirmData); | ||||||
|  |  | ||||||
|  |       cy.ddcServerSideRaceConditionTest(confirmData, globalState); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it("[Payment] Client-side DDC race condition handling", () => { | ||||||
|  |       const createData = | ||||||
|  |         getConnectorDetails(connector)["card_pm"]["PaymentIntent"]; | ||||||
|  |       const confirmData = | ||||||
|  |         getConnectorDetails(connector)["card_pm"]["DDCRaceConditionClientSide"]; | ||||||
|  |  | ||||||
|  |       cy.createPaymentIntentTest( | ||||||
|  |         fixtures.createPaymentBody, | ||||||
|  |         createData, | ||||||
|  |         "three_ds", | ||||||
|  |         "automatic", | ||||||
|  |         globalState | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (shouldContinue) | ||||||
|  |         shouldContinue = utils.should_continue_further(createData); | ||||||
|  |  | ||||||
|  |       cy.confirmCallTest(fixtures.confirmBody, confirmData, true, globalState); | ||||||
|  |  | ||||||
|  |       if (shouldContinue) | ||||||
|  |         shouldContinue = utils.should_continue_further(confirmData); | ||||||
|  |  | ||||||
|  |       cy.ddcClientSideRaceConditionTest(confirmData, globalState); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -4066,3 +4066,96 @@ Cypress.Commands.add("setConfigs", (globalState, key, value, requestType) => { | |||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | // DDC Race Condition Test Commands | ||||||
|  | Cypress.Commands.add( | ||||||
|  |   "ddcServerSideRaceConditionTest", | ||||||
|  |   (confirmData, globalState) => { | ||||||
|  |     const ddcConfig = confirmData.DDCConfig; | ||||||
|  |     const paymentId = globalState.get("paymentID"); | ||||||
|  |     const merchantId = globalState.get("merchantId"); | ||||||
|  |     const completeUrl = `${Cypress.env("BASEURL")}/payments/${paymentId}/${merchantId}${ddcConfig.completeUrlPath}`; | ||||||
|  |  | ||||||
|  |     cy.request({ | ||||||
|  |       method: "GET", | ||||||
|  |       url: completeUrl, | ||||||
|  |       qs: { | ||||||
|  |         [ddcConfig.collectionReferenceParam]: ddcConfig.firstSubmissionValue, | ||||||
|  |       }, | ||||||
|  |       failOnStatusCode: false, | ||||||
|  |     }).then((firstResponse) => { | ||||||
|  |       if ( | ||||||
|  |         firstResponse.status === 400 && | ||||||
|  |         firstResponse.body?.error?.message?.includes("No eligible connector") | ||||||
|  |       ) { | ||||||
|  |         throw new Error( | ||||||
|  |           `Connector configuration issue detected. Response: ${JSON.stringify(firstResponse.body)}` | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       expect(firstResponse.status).to.be.oneOf([200, 302]); | ||||||
|  |       cy.log(`First request status: ${firstResponse.status}`); | ||||||
|  |  | ||||||
|  |       cy.request({ | ||||||
|  |         method: "GET", | ||||||
|  |         url: completeUrl, | ||||||
|  |         qs: { | ||||||
|  |           [ddcConfig.collectionReferenceParam]: ddcConfig.secondSubmissionValue, | ||||||
|  |         }, | ||||||
|  |         failOnStatusCode: false, | ||||||
|  |       }).then((secondResponse) => { | ||||||
|  |         cy.log(`Second request status: ${secondResponse.status}`); | ||||||
|  |  | ||||||
|  |         expect(secondResponse.status).to.eq(ddcConfig.expectedError.status); | ||||||
|  |         expect(secondResponse.body).to.deep.equal(ddcConfig.expectedError.body); | ||||||
|  |  | ||||||
|  |         cy.log( | ||||||
|  |           "✅ Server-side race condition protection verified - second submission properly rejected" | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | Cypress.Commands.add( | ||||||
|  |   "ddcClientSideRaceConditionTest", | ||||||
|  |   (confirmData, globalState) => { | ||||||
|  |     const ddcConfig = confirmData.DDCConfig; | ||||||
|  |     const paymentId = globalState.get("paymentID"); | ||||||
|  |     const merchantId = globalState.get("merchantId"); | ||||||
|  |     const nextActionUrl = `${Cypress.env("BASEURL")}${ddcConfig.redirectUrlPath}/${paymentId}/${merchantId}/${paymentId}_1`; | ||||||
|  |  | ||||||
|  |     cy.intercept("GET", nextActionUrl, (req) => { | ||||||
|  |       req.reply((res) => { | ||||||
|  |         let modifiedHtml = res.body.toString(); | ||||||
|  |         modifiedHtml = modifiedHtml.replace( | ||||||
|  |           "</body>", | ||||||
|  |           ddcConfig.raceConditionScript + "</body>" | ||||||
|  |         ); | ||||||
|  |         res.send(modifiedHtml); | ||||||
|  |       }); | ||||||
|  |     }).as("ddcPageWithRaceCondition"); | ||||||
|  |  | ||||||
|  |     cy.intercept("GET", "**/redirect/complete/**").as("ddcSubmission"); | ||||||
|  |     const delayBeforeSubmission = ddcConfig.delayBeforeSubmission || 2000; | ||||||
|  |  | ||||||
|  |     cy.visit(nextActionUrl); | ||||||
|  |     cy.wait("@ddcPageWithRaceCondition"); | ||||||
|  |     cy.wait("@ddcSubmission"); | ||||||
|  |     cy.wait(delayBeforeSubmission); | ||||||
|  |  | ||||||
|  |     cy.get("@ddcSubmission.all").should("have.length", 1); | ||||||
|  |  | ||||||
|  |     cy.get("@ddcSubmission").then((interception) => { | ||||||
|  |       const collectionRef = | ||||||
|  |         interception.request.query[ddcConfig.collectionReferenceParam] || ""; | ||||||
|  |       cy.log( | ||||||
|  |         `Single submission detected with ${ddcConfig.collectionReferenceParam}: "${collectionRef}"` | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     cy.log( | ||||||
|  |       "✅ Client-side race condition protection verified - only one submission occurred" | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Kashif
					Kashif