mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 17:19:15 +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