mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 00:49:42 +08:00
feat: add test_mode for quickly testing payout links (#5669)
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
@ -3783,7 +3783,15 @@
|
||||
"$ref": "#/components/schemas/BusinessGenericLinkConfig"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payout_test_mode": {
|
||||
"type": "boolean",
|
||||
"description": "Allows for removing any validations / pre-requisites which are necessary in a production environment",
|
||||
"default": false,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -14844,6 +14852,12 @@
|
||||
"description": "List of payout methods shown on collect UI",
|
||||
"example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]",
|
||||
"nullable": true
|
||||
},
|
||||
"test_mode": {
|
||||
"type": "boolean",
|
||||
"description": "`test_mode` allows for opening payout links without any restrictions. This removes\n- domain name validations\n- check for making sure link is accessed within an iframe",
|
||||
"example": false,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7594,7 +7594,15 @@
|
||||
"$ref": "#/components/schemas/BusinessGenericLinkConfig"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payout_test_mode": {
|
||||
"type": "boolean",
|
||||
"description": "Allows for removing any validations / pre-requisites which are necessary in a production environment",
|
||||
"default": false,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -19241,6 +19249,12 @@
|
||||
"description": "List of payout methods shown on collect UI",
|
||||
"example": "[{\"payment_method\": \"bank_transfer\", \"payment_method_types\": [\"ach\", \"bacs\"]}]",
|
||||
"nullable": true
|
||||
},
|
||||
"test_mode": {
|
||||
"type": "boolean",
|
||||
"description": "`test_mode` allows for opening payout links without any restrictions. This removes\n- domain name validations\n- check for making sure link is accessed within an iframe",
|
||||
"example": false,
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2517,6 +2517,10 @@ pub struct BusinessCollectLinkConfig {
|
||||
pub struct BusinessPayoutLinkConfig {
|
||||
#[serde(flatten)]
|
||||
pub config: BusinessGenericLinkConfig,
|
||||
|
||||
/// Allows for removing any validations / pre-requisites which are necessary in a production environment
|
||||
#[schema(value_type = Option<bool>, default = false)]
|
||||
pub payout_test_mode: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)]
|
||||
|
||||
@ -203,6 +203,12 @@ pub struct PayoutCreatePayoutLinkConfig {
|
||||
/// List of payout methods shown on collect UI
|
||||
#[schema(value_type = Option<Vec<EnabledPaymentMethod>>, example = r#"[{"payment_method": "bank_transfer", "payment_method_types": ["ach", "bacs"]}]"#)]
|
||||
pub enabled_payment_methods: Option<Vec<link_utils::EnabledPaymentMethod>>,
|
||||
|
||||
/// `test_mode` allows for opening payout links without any restrictions. This removes
|
||||
/// - domain name validations
|
||||
/// - check for making sure link is accessed within an iframe
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
pub test_mode: Option<bool>,
|
||||
}
|
||||
|
||||
/// The payout method information required for carrying out a payout
|
||||
@ -775,6 +781,7 @@ pub struct PayoutLinkDetails {
|
||||
pub amount: common_utils::types::StringMajorUnit,
|
||||
pub currency: common_enums::Currency,
|
||||
pub locale: String,
|
||||
pub test_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
@ -790,4 +797,5 @@ pub struct PayoutLinkStatusDetails {
|
||||
pub error_message: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub ui_config: link_utils::GenericLinkUiConfigFormData,
|
||||
pub test_mode: bool,
|
||||
}
|
||||
|
||||
@ -167,6 +167,8 @@ pub struct PayoutLinkData {
|
||||
pub currency: enums::Currency,
|
||||
/// A list of allowed domains (glob patterns) where this link can be embedded / opened from
|
||||
pub allowed_domains: HashSet<String>,
|
||||
/// `test_mode` can be used for testing payout links without any restrictions
|
||||
pub test_mode: Option<bool>,
|
||||
}
|
||||
|
||||
crate::impl_to_sql_from_sql_json!(PayoutLinkData);
|
||||
|
||||
@ -552,6 +552,7 @@ common_utils::impl_to_sql_from_sql_json!(BusinessPaymentLinkConfig);
|
||||
pub struct BusinessPayoutLinkConfig {
|
||||
#[serde(flatten)]
|
||||
pub config: BusinessGenericLinkConfig,
|
||||
pub payout_test_mode: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
// @ts-check
|
||||
|
||||
// Top level checks
|
||||
// @ts-ignore
|
||||
var payoutDetails = window.__PAYOUT_DETAILS;
|
||||
var isTestMode = payoutDetails.test_mode;
|
||||
|
||||
var isFramed = false;
|
||||
try {
|
||||
isFramed = window.parent.location !== window.location;
|
||||
@ -12,7 +16,7 @@ try {
|
||||
}
|
||||
|
||||
// Remove the script from DOM incase it's not iframed
|
||||
if (!isFramed) {
|
||||
if (!isTestMode && !isFramed) {
|
||||
function initializePayoutSDK() {
|
||||
var errMsg = "{{i18n_not_allowed}}";
|
||||
var contentElement = document.getElementById("payout-link");
|
||||
|
||||
@ -79,7 +79,10 @@ pub async fn initiate_payout_link(
|
||||
message: "payout link not found".to_string(),
|
||||
})?;
|
||||
|
||||
validator::validate_payout_link_render_request(request_headers, &payout_link)?;
|
||||
let allowed_domains = validator::validate_payout_link_render_request_and_get_allowed_domains(
|
||||
request_headers,
|
||||
&payout_link,
|
||||
)?;
|
||||
|
||||
// Check status and return form data accordingly
|
||||
let has_expired = common_utils::date_time::now() > payout_link.expiry;
|
||||
@ -120,7 +123,7 @@ pub async fn initiate_payout_link(
|
||||
|
||||
Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
|
||||
GenericLinks {
|
||||
allowed_domains: (link_data.allowed_domains),
|
||||
allowed_domains,
|
||||
data: GenericLinksData::ExpiredLink(expired_link_data),
|
||||
locale,
|
||||
},
|
||||
@ -204,6 +207,7 @@ pub async fn initiate_payout_link(
|
||||
amount,
|
||||
currency: payout.destination_currency,
|
||||
locale: locale.clone(),
|
||||
test_mode: link_data.test_mode.unwrap_or(false),
|
||||
};
|
||||
|
||||
let serialized_css_content = String::new();
|
||||
@ -224,7 +228,7 @@ pub async fn initiate_payout_link(
|
||||
};
|
||||
Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
|
||||
GenericLinks {
|
||||
allowed_domains: (link_data.allowed_domains),
|
||||
allowed_domains,
|
||||
data: GenericLinksData::PayoutLink(generic_form_data),
|
||||
locale,
|
||||
},
|
||||
@ -249,6 +253,7 @@ pub async fn initiate_payout_link(
|
||||
error_code: payout_attempt.error_code,
|
||||
error_message: payout_attempt.error_message,
|
||||
ui_config: ui_config_data,
|
||||
test_mode: link_data.test_mode.unwrap_or(false),
|
||||
};
|
||||
|
||||
let serialized_css_content = String::new();
|
||||
@ -267,7 +272,7 @@ pub async fn initiate_payout_link(
|
||||
};
|
||||
Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
|
||||
GenericLinks {
|
||||
allowed_domains: (link_data.allowed_domains),
|
||||
allowed_domains,
|
||||
data: GenericLinksData::PayoutLinkStatus(generic_status_data),
|
||||
locale,
|
||||
},
|
||||
|
||||
@ -4,7 +4,7 @@ pub mod helpers;
|
||||
pub mod retry;
|
||||
pub mod transformers;
|
||||
pub mod validator;
|
||||
use std::vec::IntoIter;
|
||||
use std::{collections::HashSet, vec::IntoIter};
|
||||
|
||||
use api_models::{self, enums as api_enums, payouts::PayoutLinkResponse};
|
||||
#[cfg(feature = "payout_retry")]
|
||||
@ -28,7 +28,7 @@ use futures::future::join_all;
|
||||
use masking::{PeekInterface, Secret};
|
||||
#[cfg(feature = "payout_retry")]
|
||||
use retry::GsmValidation;
|
||||
use router_env::{instrument, logger, tracing};
|
||||
use router_env::{instrument, logger, tracing, Env};
|
||||
use scheduler::utils as pt_utils;
|
||||
use serde_json;
|
||||
use time::Duration;
|
||||
@ -2628,15 +2628,34 @@ pub async fn create_payout_link(
|
||||
.and_then(|config| config.ui_config.clone())
|
||||
.or(profile_ui_config);
|
||||
|
||||
// Validate allowed_domains presence
|
||||
let allowed_domains = profile_config
|
||||
let test_mode_in_config = payout_link_config_req
|
||||
.as_ref()
|
||||
.map(|config| config.config.allowed_domains.to_owned())
|
||||
.get_required_value("allowed_domains")
|
||||
.change_context(errors::ApiErrorResponse::LinkConfigurationError {
|
||||
message: "Payout links cannot be used without setting allowed_domains in profile"
|
||||
.to_string(),
|
||||
})?;
|
||||
.and_then(|config| config.test_mode)
|
||||
.or_else(|| profile_config.as_ref().and_then(|c| c.payout_test_mode));
|
||||
let is_test_mode_enabled = test_mode_in_config.unwrap_or(false);
|
||||
|
||||
let allowed_domains = match (router_env::which(), is_test_mode_enabled) {
|
||||
// Throw error in case test_mode was enabled in production
|
||||
(Env::Production, true) => Err(report!(errors::ApiErrorResponse::LinkConfigurationError {
|
||||
message: "test_mode cannot be true for creating payout_links in production".to_string()
|
||||
})),
|
||||
// Send empty set of whitelisted domains
|
||||
(_, true) => {
|
||||
Ok(HashSet::new())
|
||||
},
|
||||
// Otherwise, fetch and use allowed domains from profile config
|
||||
(_, false) => {
|
||||
profile_config
|
||||
.as_ref()
|
||||
.map(|config| config.config.allowed_domains.to_owned())
|
||||
.get_required_value("allowed_domains")
|
||||
.change_context(errors::ApiErrorResponse::LinkConfigurationError {
|
||||
message:
|
||||
"Payout links cannot be used without setting allowed_domains in profile. If you're using a non-production environment, you can set test_mode to true while in payout_link_config"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
}?;
|
||||
|
||||
// Form data to be injected in the link
|
||||
let (logo, merchant_name, theme) = match ui_config {
|
||||
@ -2699,6 +2718,7 @@ pub async fn create_payout_link(
|
||||
amount: MinorUnit::from(*amount),
|
||||
currency: *currency,
|
||||
allowed_domains,
|
||||
test_mode: test_mode_in_config,
|
||||
};
|
||||
|
||||
create_payout_link_db_entry(state, merchant_id, &data, req.return_url.clone()).await
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use actix_web::http::header;
|
||||
#[cfg(feature = "olap")]
|
||||
use common_utils::errors::CustomResult;
|
||||
@ -5,7 +7,7 @@ use common_utils::validation::validate_domain_against_allowed_domains;
|
||||
use diesel_models::generic_link::PayoutLink;
|
||||
use error_stack::{report, ResultExt};
|
||||
pub use hyperswitch_domain_models::errors::StorageError;
|
||||
use router_env::{instrument, tracing};
|
||||
use router_env::{instrument, tracing, which as router_env_which, Env};
|
||||
use url::Url;
|
||||
|
||||
use super::helpers;
|
||||
@ -225,88 +227,105 @@ pub(super) fn validate_payout_list_request_for_joins(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_payout_link_render_request(
|
||||
pub fn validate_payout_link_render_request_and_get_allowed_domains(
|
||||
request_headers: &header::HeaderMap,
|
||||
payout_link: &PayoutLink,
|
||||
) -> RouterResult<()> {
|
||||
) -> RouterResult<HashSet<String>> {
|
||||
let link_id = payout_link.link_id.to_owned();
|
||||
let link_data = payout_link.link_data.to_owned();
|
||||
|
||||
// Fetch destination is "iframe"
|
||||
match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) {
|
||||
Some("iframe") => Ok(()),
|
||||
Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when requested through {}",
|
||||
link_id, requestor
|
||||
)
|
||||
}),
|
||||
None => Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when sec-fetch-dest is not present in request headers",
|
||||
link_id
|
||||
)
|
||||
}),
|
||||
}?;
|
||||
let is_test_mode_enabled = link_data.test_mode.unwrap_or(false);
|
||||
|
||||
// Validate origin / referer
|
||||
let domain_in_req = {
|
||||
let origin_or_referer = request_headers
|
||||
.get("origin")
|
||||
.or_else(|| request_headers.get("referer"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
match (router_env_which(), is_test_mode_enabled) {
|
||||
// Throw error in case test_mode was enabled in production
|
||||
(Env::Production, true) => Err(report!(errors::ApiErrorResponse::LinkConfigurationError {
|
||||
message: "test_mode cannot be true for rendering payout_links in production"
|
||||
.to_string()
|
||||
})),
|
||||
// Skip all validations when test mode is enabled in non prod env
|
||||
(_, true) => Ok(HashSet::new()),
|
||||
// Otherwise, perform validations
|
||||
(_, false) => {
|
||||
// Fetch destination is "iframe"
|
||||
match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) {
|
||||
Some("iframe") => Ok(()),
|
||||
Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when origin or referer is not present in request headers",
|
||||
link_id
|
||||
)
|
||||
})?;
|
||||
|
||||
let url = Url::parse(origin_or_referer)
|
||||
.map_err(|_| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when requested through {}",
|
||||
link_id, requestor
|
||||
)
|
||||
}),
|
||||
None => Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!("Invalid URL found in request headers {}", origin_or_referer)
|
||||
})?;
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when sec-fetch-dest is not present in request headers",
|
||||
link_id
|
||||
)
|
||||
}),
|
||||
}?;
|
||||
|
||||
url.host_str()
|
||||
.and_then(|host| url.port().map(|port| format!("{}:{}", host, port)))
|
||||
.or_else(|| url.host_str().map(String::from))
|
||||
.ok_or_else(|| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
// Validate origin / referer
|
||||
let domain_in_req = {
|
||||
let origin_or_referer = request_headers
|
||||
.get("origin")
|
||||
.or_else(|| request_headers.get("referer"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden when origin or referer is not present in request headers",
|
||||
link_id
|
||||
)
|
||||
})?;
|
||||
|
||||
let url = Url::parse(origin_or_referer)
|
||||
.map_err(|_| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!("Invalid URL found in request headers {}", origin_or_referer)
|
||||
})?;
|
||||
|
||||
url.host_str()
|
||||
.and_then(|host| url.port().map(|port| format!("{}:{}", host, port)))
|
||||
.or_else(|| url.host_str().map(String::from))
|
||||
.ok_or_else(|| {
|
||||
report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!("host or port not found in request headers {:?}", url)
|
||||
})?
|
||||
};
|
||||
|
||||
if validate_domain_against_allowed_domains(
|
||||
&domain_in_req,
|
||||
link_data.allowed_domains.clone(),
|
||||
) {
|
||||
Ok(link_data.allowed_domains)
|
||||
} else {
|
||||
Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden from requestor - {}",
|
||||
link_id, domain_in_req
|
||||
)
|
||||
})
|
||||
})
|
||||
.attach_printable_lazy(|| {
|
||||
format!("host or port not found in request headers {:?}", url)
|
||||
})?
|
||||
};
|
||||
|
||||
if validate_domain_against_allowed_domains(&domain_in_req, link_data.allowed_domains) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(report!(errors::ApiErrorResponse::AccessForbidden {
|
||||
resource: "payout_link".to_string(),
|
||||
}))
|
||||
.attach_printable_lazy(|| {
|
||||
format!(
|
||||
"Access to payout_link [{}] is forbidden from requestor - {}",
|
||||
link_id, domain_in_req
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1789,6 +1789,7 @@ impl ForeignFrom<api_models::admin::BusinessPayoutLinkConfig>
|
||||
fn foreign_from(item: api_models::admin::BusinessPayoutLinkConfig) -> Self {
|
||||
Self {
|
||||
config: item.config.foreign_into(),
|
||||
payout_test_mode: item.payout_test_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1799,6 +1800,7 @@ impl ForeignFrom<diesel_models::business_profile::BusinessPayoutLinkConfig>
|
||||
fn foreign_from(item: diesel_models::business_profile::BusinessPayoutLinkConfig) -> Self {
|
||||
Self {
|
||||
config: item.config.foreign_into(),
|
||||
payout_test_mode: item.payout_test_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user