diff --git a/crates/hyperswitch_connectors/src/connectors/worldpay.rs b/crates/hyperswitch_connectors/src/connectors/worldpay.rs index 009a02f7ef..fcb582830b 100644 --- a/crates/hyperswitch_connectors/src/connectors/worldpay.rs +++ b/crates/hyperswitch_connectors/src/connectors/worldpay.rs @@ -798,7 +798,16 @@ impl ConnectorIntegration "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!( "{}api/payments/{connector_payment_id}/{stage}", diff --git a/crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs b/crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs index 14bcd0afaf..3a2fe91b23 100644 --- a/crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/worldpay/transformers.rs @@ -883,7 +883,29 @@ impl TryFrom<&types::PaymentsCompleteAuthorizeRouterData> for WorldpayCompleteAu .as_ref() .and_then(|redirect_response| redirect_response.params.as_ref()) .ok_or(errors::ConnectorError::ResponseDeserializationFailed)?; - serde_urlencoded::from_str::(params.peek()) - .change_context(errors::ConnectorError::ResponseDeserializationFailed) + + let parsed_request = serde_urlencoded::from_str::(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(), + ), + } } } diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 9b6c091a57..f79e6d7f5e 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -1181,7 +1181,7 @@ pub fn build_redirection_form( #loader1 { width: 500px, } - @media max-width: 600px { + @media (max-width: 600px) { #loader1 { width: 200px } @@ -1748,7 +1748,7 @@ pub fn build_redirection_form( #loader1 { width: 500px; } - @media max-width: 600px { + @media (max-width: 600px) { #loader1 { width: 200px; } @@ -1777,7 +1777,21 @@ pub fn build_redirection_form( script { (PreEscaped(format!( r#" + var ddcProcessed = false; + var timeoutHandle = null; + 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 redirectUrl = window.location.origin + redirectPathname; try {{ @@ -1796,12 +1810,17 @@ pub fn build_redirection_form( window.location.replace(redirectUrl); }} }} catch (error) {{ + console.error("Error submitting DDC:", error); window.location.replace(redirectUrl); }} }} var allowedHost = "{}"; var collectionField = "{}"; window.addEventListener("message", function(event) {{ + if (ddcProcessed) {{ + console.log("DDC already processed, ignoring message event"); + return; + }} if (event.origin === allowedHost) {{ try {{ var data = JSON.parse(event.data); @@ -1821,8 +1840,13 @@ pub fn build_redirection_form( submitCollectionReference(""); }}); - // Redirect within 8 seconds if no collection reference is received - window.setTimeout(submitCollectionReference, 8000); + // Timeout after 10 seconds and will submit empty collection reference + 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::>().join("/"), |host| format!("{}://{}", endpoint.scheme(), host)), collection_id.clone().unwrap_or("".to_string()))) diff --git a/cypress-tests/cypress/e2e/configs/Payment/Commons.js b/cypress-tests/cypress/e2e/configs/Payment/Commons.js index 78bd3b073f..959e69d0e3 100644 --- a/cypress-tests/cypress/e2e/configs/Payment/Commons.js +++ b/cypress-tests/cypress/e2e/configs/Payment/Commons.js @@ -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: ` + + `, + }, + }), }, upi_pm: { PaymentIntent: getCustomExchange({ diff --git a/cypress-tests/cypress/e2e/configs/Payment/Utils.js b/cypress-tests/cypress/e2e/configs/Payment/Utils.js index c95ee19cc1..7065f127fd 100644 --- a/cypress-tests/cypress/e2e/configs/Payment/Utils.js +++ b/cypress-tests/cypress/e2e/configs/Payment/Utils.js @@ -399,6 +399,7 @@ export const CONNECTOR_LISTS = { // "cybersource", // issues with MULTIPLE_CONNECTORS handling "paypal", ], + DDC_RACE_CONDITION: ["worldpay"], // Add more inclusion lists }, }; diff --git a/cypress-tests/cypress/e2e/configs/Payment/WorldPay.js b/cypress-tests/cypress/e2e/configs/Payment/WorldPay.js index 098c46dae3..f05a92f350 100644 --- a/cypress-tests/cypress/e2e/configs/Payment/WorldPay.js +++ b/cypress-tests/cypress/e2e/configs/Payment/WorldPay.js @@ -45,6 +45,14 @@ const successfulThreeDsTestCardDetailsRequest = { 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 = { card: { last4: "4242", @@ -119,7 +127,7 @@ export const connectorDetails = { No3DSManualCapture: { Request: { payment_method: "card", - payment_method_type: "debit", + payment_method_type: "credit", payment_method_data: { card: successfulNoThreeDsCardDetailsRequest, }, @@ -141,7 +149,7 @@ export const connectorDetails = { No3DSAutoCapture: { Request: { payment_method: "card", - payment_method_type: "debit", + payment_method_type: "credit", payment_method_data: { card: successfulNoThreeDsCardDetailsRequest, }, @@ -624,6 +632,7 @@ export const connectorDetails = { ZeroAuthConfirmPayment: { Request: { payment_method: "card", + payment_method_type: "debit", payment_method_data: { 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: { Request: { 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: ` + + `, + }, + }, }, pm_list: { PmListResponse: { diff --git a/cypress-tests/cypress/e2e/spec/Payment/00030-DDCRaceCondition.cy.js b/cypress-tests/cypress/e2e/spec/Payment/00030-DDCRaceCondition.cy.js new file mode 100644 index 0000000000..c7f17605b9 --- /dev/null +++ b/cypress-tests/cypress/e2e/spec/Payment/00030-DDCRaceCondition.cy.js @@ -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); + }); + }); +}); diff --git a/cypress-tests/cypress/support/commands.js b/cypress-tests/cypress/support/commands.js index a600bc6890..1c509be0bd 100644 --- a/cypress-tests/cypress/support/commands.js +++ b/cypress-tests/cypress/support/commands.js @@ -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( + "", + ddcConfig.raceConditionScript + "" + ); + 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" + ); + } +);