fix(connector): [Worldpay] handle multiple ddc submission for CompleteAuthorize (#8741)

This commit is contained in:
Kashif
2025-07-29 13:58:34 +05:30
committed by GitHub
parent 4587564824
commit f6cdddcb98
8 changed files with 557 additions and 9 deletions

View File

@ -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}",

View File

@ -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(),
),
}
} }
} }

View File

@ -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())))

View File

@ -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({

View File

@ -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
}, },
}; };

View File

@ -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: {

View File

@ -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);
});
});
});

View File

@ -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"
);
}
);