#[cfg(any(feature = "tempo", feature = "stripe"))]
use crate::error::Result;
#[cfg(any(feature = "tempo", feature = "stripe"))]
use crate::protocol::core::PaymentChallenge;
use crate::protocol::core::{PaymentCredential, Receipt};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::traits::{ChargeMethod, VerificationError};
const SECRET_KEY_ENV_VAR: &str = "MPP_SECRET_KEY";
const DEFAULT_DECIMALS: u32 = 6;
const REALM_ENV_VARS: &[&str] = &[
"MPP_REALM",
"FLY_APP_NAME",
"HEROKU_APP_NAME",
"HOST",
"HOSTNAME",
"RAILWAY_PUBLIC_DOMAIN",
"RENDER_EXTERNAL_HOSTNAME",
"VERCEL_URL",
"WEBSITE_HOSTNAME",
];
const DEFAULT_REALM: &str = "MPP Payment";
pub(crate) fn detect_realm() -> String {
for name in REALM_ENV_VARS {
if let Ok(value) = std::env::var(name) {
if !value.is_empty() {
return value;
}
}
}
DEFAULT_REALM.to_string()
}
#[derive(Debug)]
pub struct SessionVerifyResult {
pub receipt: Receipt,
pub management_response: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct Mpp<M, S = ()> {
method: M,
session_method: Option<S>,
realm: String,
secret_key: String,
currency: Option<String>,
recipient: Option<String>,
decimals: u32,
fee_payer: bool,
chain_id: Option<u64>,
}
impl<M> Mpp<M, ()>
where
M: ChargeMethod,
{
pub fn new(method: M, realm: impl Into<String>, secret_key: impl Into<String>) -> Mpp<M, ()> {
Mpp {
method,
session_method: None,
realm: realm.into(),
secret_key: secret_key.into(),
currency: None,
recipient: None,
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
}
}
}
impl<M> Mpp<M, ()>
where
M: ChargeMethod,
{
#[cfg(test)]
pub(crate) fn new_with_config(
method: M,
realm: impl Into<String>,
secret_key: impl Into<String>,
currency: impl Into<String>,
recipient: impl Into<String>,
) -> Self {
Mpp {
method,
session_method: None,
realm: realm.into(),
secret_key: secret_key.into(),
currency: Some(currency.into()),
recipient: Some(recipient.into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
}
}
}
impl<M, S> Mpp<M, S>
where
M: ChargeMethod,
{
pub fn with_session_method<S2>(self, session_method: S2) -> Mpp<M, S2> {
Mpp {
method: self.method,
session_method: Some(session_method),
realm: self.realm,
secret_key: self.secret_key,
currency: self.currency,
recipient: self.recipient,
decimals: self.decimals,
fee_payer: self.fee_payer,
chain_id: self.chain_id,
}
}
pub fn realm(&self) -> &str {
&self.realm
}
pub fn method_name(&self) -> &str {
self.method.method()
}
pub fn currency(&self) -> Option<&str> {
self.currency.as_deref()
}
pub fn recipient(&self) -> Option<&str> {
self.recipient.as_deref()
}
pub fn decimals(&self) -> u32 {
self.decimals
}
pub fn fee_payer(&self) -> bool {
self.fee_payer
}
pub fn chain_id(&self) -> Option<u64> {
self.chain_id
}
fn verify_hmac_and_expiry(
&self,
credential: &PaymentCredential,
) -> std::result::Result<(), VerificationError> {
let expected_id = crate::protocol::core::compute_challenge_id(
&self.secret_key,
&self.realm,
credential.challenge.method.as_str(),
credential.challenge.intent.as_str(),
credential.challenge.request.raw(),
credential.challenge.expires.as_deref(),
credential.challenge.digest.as_deref(),
credential.challenge.opaque.as_ref().map(|o| o.raw()),
);
if credential.challenge.id != expected_id {
return Err(VerificationError::with_code(
"Challenge ID mismatch - not issued by this server",
crate::protocol::traits::ErrorCode::CredentialMismatch,
));
}
if let Some(ref expires) = credential.challenge.expires {
if let Ok(expires_at) =
time::OffsetDateTime::parse(expires, &time::format_description::well_known::Rfc3339)
{
if expires_at <= time::OffsetDateTime::now_utc() {
return Err(VerificationError::expired(format!(
"Challenge expired at {}",
expires
)));
}
} else {
return Err(VerificationError::new(
"Invalid expires timestamp in challenge",
));
}
}
Ok(())
}
#[cfg(feature = "tempo")]
fn require_bound_config(&self) -> Result<(&str, &str)> {
let currency = self.currency.as_deref().ok_or_else(|| {
crate::error::MppError::InvalidConfig(
"currency not configured — use Mpp::create() or set currency".into(),
)
})?;
let recipient = self.recipient.as_deref().ok_or_else(|| {
crate::error::MppError::InvalidConfig(
"recipient not configured — use Mpp::create() or set recipient".into(),
)
})?;
Ok((currency, recipient))
}
#[cfg(feature = "tempo")]
pub fn charge(&self, amount: &str) -> Result<PaymentChallenge> {
self.charge_with_options(amount, super::ChargeOptions::default())
}
#[cfg(feature = "tempo")]
pub fn charge_with_options(
&self,
amount: &str,
options: super::ChargeOptions<'_>,
) -> Result<PaymentChallenge> {
let (currency, recipient) = self.require_bound_config()?;
let base_units = super::parse_dollar_amount(amount, self.decimals)?;
let mut request = ChargeRequest {
amount: base_units,
currency: currency.to_string(),
recipient: Some(recipient.to_string()),
description: options.description.map(|s| s.to_string()),
external_id: options.external_id.map(|s| s.to_string()),
..Default::default()
};
{
let mut details = serde_json::Map::new();
if options.fee_payer || self.fee_payer {
details.insert("feePayer".into(), serde_json::json!(true));
}
if let Some(chain_id) = self.chain_id {
details.insert("chainId".into(), serde_json::json!(chain_id));
}
if !details.is_empty() {
request.method_details = Some(serde_json::Value::Object(details));
}
}
crate::protocol::methods::tempo::charge_challenge_with_options(
&self.secret_key,
&self.realm,
&request,
options.expires,
options.description,
)
}
#[cfg(feature = "tempo")]
pub fn charge_challenge(
&self,
amount: &str,
currency: &str,
recipient: &str,
) -> Result<PaymentChallenge> {
crate::protocol::methods::tempo::charge_challenge(
&self.secret_key,
&self.realm,
amount,
currency,
recipient,
)
}
#[cfg(feature = "tempo")]
pub fn charge_challenge_with_options(
&self,
request: &ChargeRequest,
expires: Option<&str>,
description: Option<&str>,
) -> Result<PaymentChallenge> {
crate::protocol::methods::tempo::charge_challenge_with_options(
&self.secret_key,
&self.realm,
request,
expires,
description,
)
}
pub async fn verify_credential(
&self,
credential: &PaymentCredential,
) -> std::result::Result<Receipt, VerificationError> {
let request: ChargeRequest = credential
.challenge
.request
.decode()
.map_err(|e| VerificationError::new(format!("Failed to decode request: {}", e)))?;
self.verify(credential, &request).await
}
pub async fn verify_credential_with_expected_request(
&self,
credential: &PaymentCredential,
expected: &ChargeRequest,
) -> std::result::Result<Receipt, VerificationError> {
let request: ChargeRequest = credential
.challenge
.request
.decode()
.map_err(|e| VerificationError::new(format!("Failed to decode request: {}", e)))?;
if request.amount != expected.amount {
return Err(VerificationError::with_code(
format!(
"Amount mismatch: credential has {} but endpoint expects {}",
request.amount, expected.amount
),
crate::protocol::traits::ErrorCode::CredentialMismatch,
));
}
if request.currency != expected.currency {
return Err(VerificationError::with_code(
format!(
"Currency mismatch: credential has {} but endpoint expects {}",
request.currency, expected.currency
),
crate::protocol::traits::ErrorCode::CredentialMismatch,
));
}
if request.recipient != expected.recipient {
return Err(VerificationError::with_code(
"Recipient mismatch: credential was issued for a different recipient",
crate::protocol::traits::ErrorCode::CredentialMismatch,
));
}
self.verify(credential, &request).await
}
pub async fn verify(
&self,
credential: &PaymentCredential,
request: &ChargeRequest,
) -> std::result::Result<Receipt, VerificationError> {
self.verify_hmac_and_expiry(credential)?;
let receipt = self.method.verify(credential, request).await?;
Ok(receipt)
}
}
impl<M, S> Mpp<M, S>
where
M: ChargeMethod,
S: crate::protocol::traits::SessionMethod,
{
#[cfg(feature = "tempo")]
pub fn session_challenge(
&self,
amount: &str,
currency: &str,
recipient: &str,
) -> crate::error::Result<PaymentChallenge> {
use crate::protocol::intents::SessionRequest;
let request = SessionRequest {
amount: amount.to_string(),
currency: currency.to_string(),
recipient: Some(recipient.to_string()),
..Default::default()
};
let encoded = crate::protocol::core::Base64UrlJson::from_typed(&request)?;
let id = crate::protocol::methods::tempo::generate_challenge_id(
&self.secret_key,
&self.realm,
"tempo",
"session",
encoded.raw(),
None,
None,
None,
);
Ok(PaymentChallenge {
id,
realm: self.realm.clone(),
method: "tempo".into(),
intent: "session".into(),
request: encoded,
expires: None,
description: None,
digest: None,
opaque: None,
})
}
#[cfg(feature = "tempo")]
pub fn session_challenge_with_details(
&self,
amount: &str,
currency: &str,
recipient: &str,
options: super::SessionChallengeOptions<'_>,
) -> crate::error::Result<PaymentChallenge> {
use crate::protocol::intents::SessionRequest;
let session = self.session_method.as_ref();
let mut method_details = session.and_then(|s| s.challenge_method_details());
if options.fee_payer || self.fee_payer {
let details = method_details.get_or_insert_with(|| serde_json::json!({}));
if let Some(obj) = details.as_object_mut() {
obj.insert("feePayer".to_string(), serde_json::json!(true));
}
}
let request = SessionRequest {
amount: amount.to_string(),
unit_type: options.unit_type.map(|s| s.to_string()),
currency: currency.to_string(),
recipient: Some(recipient.to_string()),
suggested_deposit: options.suggested_deposit.map(|s| s.to_string()),
method_details,
..Default::default()
};
let encoded = crate::protocol::core::Base64UrlJson::from_typed(&request)?;
let id = crate::protocol::methods::tempo::generate_challenge_id(
&self.secret_key,
&self.realm,
"tempo",
"session",
encoded.raw(),
options.expires,
None,
None,
);
Ok(PaymentChallenge {
id,
realm: self.realm.clone(),
method: "tempo".into(),
intent: "session".into(),
request: encoded,
expires: options.expires.map(|s| s.to_string()),
description: options.description.map(|s| s.to_string()),
digest: None,
opaque: None,
})
}
pub async fn verify_session(
&self,
credential: &PaymentCredential,
) -> std::result::Result<SessionVerifyResult, crate::protocol::traits::VerificationError> {
let session = self.session_method.as_ref().ok_or_else(|| {
crate::protocol::traits::VerificationError::new("No session method configured")
})?;
self.verify_hmac_and_expiry(credential)?;
let request: crate::protocol::intents::SessionRequest =
credential.challenge.request.decode().map_err(|e| {
crate::protocol::traits::VerificationError::new(format!(
"Failed to decode session request: {}",
e
))
})?;
let receipt = session.verify_session(credential, &request).await?;
let management_response = session.respond(credential, &receipt);
Ok(SessionVerifyResult {
receipt,
management_response,
})
}
}
#[cfg(feature = "tempo")]
impl Mpp<super::TempoChargeMethod<super::TempoProvider>> {
pub fn create(builder: super::TempoBuilder) -> Result<Self> {
let secret_key = builder
.secret_key
.or_else(|| std::env::var(SECRET_KEY_ENV_VAR).ok())
.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
.ok_or_else(|| {
crate::error::MppError::InvalidConfig(format!(
"Missing secret key. Set {} environment variable or pass .secret_key(...).",
SECRET_KEY_ENV_VAR
))
})?;
let provider = super::tempo_provider(&builder.rpc_url)?;
let mut method = crate::protocol::methods::tempo::ChargeMethod::new(provider);
if let Some(signer) = builder.fee_payer_signer {
method = method.with_fee_payer(signer);
}
let currency = if builder.currency_explicit {
builder.currency
} else {
use crate::protocol::methods::tempo::network::TempoNetwork;
builder
.chain_id
.and_then(TempoNetwork::from_chain_id)
.map(|n| n.default_currency().to_string())
.unwrap_or_else(|| crate::protocol::methods::tempo::PATH_USD.to_string())
};
Ok(Self {
method,
session_method: None,
realm: builder.realm,
secret_key,
currency: Some(currency),
recipient: Some(builder.recipient),
decimals: builder.decimals,
fee_payer: builder.fee_payer,
chain_id: builder.chain_id,
})
}
}
#[cfg(feature = "stripe")]
impl<S> Mpp<crate::protocol::methods::stripe::method::ChargeMethod, S> {
pub fn stripe_charge(&self, amount: &str) -> Result<PaymentChallenge> {
self.stripe_charge_with_options(amount, super::StripeChargeOptions::default())
}
pub fn stripe_charge_with_options(
&self,
amount: &str,
options: super::StripeChargeOptions<'_>,
) -> Result<PaymentChallenge> {
use crate::protocol::core::Base64UrlJson;
use time::{Duration, OffsetDateTime};
use crate::protocol::methods::stripe::StripeMethodDetails;
let base_units = super::parse_dollar_amount(amount, self.decimals)?;
let currency = self.currency.as_deref().unwrap_or("usd");
let details = StripeMethodDetails {
network_id: self.method.network_id().to_string(),
payment_method_types: self.method.payment_method_types().to_vec(),
metadata: options.metadata.cloned(),
};
let request = ChargeRequest {
amount: base_units,
currency: currency.to_string(),
description: options.description.map(|s| s.to_string()),
external_id: options.external_id.map(|s| s.to_string()),
method_details: Some(serde_json::to_value(&details).map_err(|e| {
crate::error::MppError::InvalidConfig(format!(
"failed to serialize methodDetails: {e}"
))
})?),
..Default::default()
};
let encoded_request = Base64UrlJson::from_typed(&request)?;
let expires = if let Some(exp) = options.expires {
exp.to_string()
} else {
let expiry_time = OffsetDateTime::now_utc() + Duration::minutes(5);
expiry_time
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| {
crate::error::MppError::InvalidConfig(format!("failed to format expires: {e}"))
})?
};
let id = crate::protocol::core::compute_challenge_id(
&self.secret_key,
&self.realm,
crate::protocol::methods::stripe::METHOD_NAME,
crate::protocol::methods::stripe::INTENT_CHARGE,
encoded_request.raw(),
Some(&expires),
None,
None,
);
Ok(PaymentChallenge {
id,
realm: self.realm.clone(),
method: crate::protocol::methods::stripe::METHOD_NAME.into(),
intent: crate::protocol::methods::stripe::INTENT_CHARGE.into(),
request: encoded_request,
expires: Some(expires),
description: options.description.map(|s| s.to_string()),
digest: None,
opaque: None,
})
}
}
#[cfg(feature = "stripe")]
impl Mpp<crate::protocol::methods::stripe::method::ChargeMethod> {
pub fn create_stripe(builder: super::StripeBuilder) -> Result<Self> {
let secret_key = builder
.hmac_secret_key
.or_else(|| std::env::var(SECRET_KEY_ENV_VAR).ok())
.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
.ok_or_else(|| {
crate::error::MppError::InvalidConfig(format!(
"Missing secret key. Set {} environment variable or pass .secret_key(...).",
SECRET_KEY_ENV_VAR
))
})?;
let mut method = crate::protocol::methods::stripe::method::ChargeMethod::new(
&builder.secret_key,
&builder.network_id,
builder.payment_method_types.clone(),
);
if let Some(api_base) = builder.stripe_api_base {
method = method.with_api_base(api_base);
}
Ok(Self {
method,
session_method: None,
realm: builder.realm,
secret_key,
currency: Some(builder.currency),
recipient: None,
decimals: builder.decimals as u32,
fee_payer: false,
chain_id: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::{ChallengeEcho, PaymentPayload};
use crate::protocol::traits::ErrorCode;
#[cfg(feature = "tempo")]
use crate::server::{tempo, ChargeOptions, TempoConfig};
use std::future::Future;
#[derive(Clone)]
struct MockMethod;
#[allow(clippy::manual_async_fn)]
impl ChargeMethod for MockMethod {
fn method(&self) -> &str {
"mock"
}
fn verify(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
async { Ok(Receipt::success("mock", "mock_ref")) }
}
}
#[derive(Clone)]
struct SuccessReceiptMethod;
#[allow(clippy::manual_async_fn)]
impl ChargeMethod for SuccessReceiptMethod {
fn method(&self) -> &str {
"mock"
}
fn verify(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
async { Ok(Receipt::success("mock", "0xabc123")) }
}
}
#[derive(Clone)]
struct FailedTransactionMethod;
#[allow(clippy::manual_async_fn)]
impl ChargeMethod for FailedTransactionMethod {
fn method(&self) -> &str {
"mock"
}
fn verify(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
async {
Err(VerificationError::transaction_failed(
"Transaction reverted on-chain",
))
}
}
}
fn test_credential(secret_key: &str) -> PaymentCredential {
let request = "eyJ0ZXN0IjoidmFsdWUifQ";
let id = {
#[cfg(feature = "tempo")]
{
crate::protocol::methods::tempo::generate_challenge_id(
secret_key,
"api.example.com",
"mock",
"charge",
request,
None,
None,
None,
)
}
#[cfg(not(feature = "tempo"))]
{
"test-id".to_string()
}
};
let echo = ChallengeEcho {
id,
realm: "api.example.com".into(),
method: "mock".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_raw(request),
expires: None,
digest: None,
opaque: None,
};
PaymentCredential::new(echo, PaymentPayload::hash("0x123"))
}
fn test_request() -> ChargeRequest {
ChargeRequest {
amount: "1000".into(),
currency: "0x123".into(),
recipient: Some("0x456".into()),
..Default::default()
}
}
#[test]
fn test_mpp_creation() {
let payment = Mpp::new(MockMethod, "api.example.com", "secret");
assert_eq!(payment.realm(), "api.example.com");
assert_eq!(payment.method_name(), "mock");
assert!(payment.currency().is_none());
assert!(payment.recipient().is_none());
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_challenge_generation() {
let payment = Mpp::new(MockMethod, "api.example.com", "test-secret");
let challenge = payment
.charge_challenge(
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_eq!(challenge.realm, "api.example.com");
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "charge");
assert_eq!(challenge.id.len(), 43);
}
#[tokio::test]
async fn test_verify_returns_receipt_for_success() {
let payment = Mpp::new(SuccessReceiptMethod, "api.example.com", "secret");
let credential = test_credential("secret");
let request = test_request();
let result = payment.verify(&credential, &request).await;
assert!(result.is_ok());
let receipt = result.unwrap();
assert!(receipt.is_success());
assert_eq!(receipt.reference, "0xabc123");
}
#[tokio::test]
async fn test_verify_returns_error_for_failed_transaction() {
use crate::error::{MppError, PaymentError};
use crate::protocol::traits::ErrorCode;
let payment = Mpp::new(FailedTransactionMethod, "api.example.com", "secret");
let credential = test_credential("secret");
let request = test_request();
let result = payment.verify(&credential, &request).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::TransactionFailed));
assert!(err.message.contains("reverted"));
let mpp_err: MppError = err.into();
let problem = mpp_err.to_problem_details(None);
assert_eq!(problem.status, 402);
}
#[cfg(feature = "tempo")]
fn create_test_mpp() -> Mpp<crate::server::TempoChargeMethod<crate::server::TempoProvider>> {
Mpp::create(
tempo(TempoConfig {
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
})
.secret_key("test-secret"),
)
.unwrap()
}
#[cfg(feature = "tempo")]
#[test]
fn test_mpp_create() {
let mpp = create_test_mpp();
assert_eq!(mpp.realm(), "MPP Payment");
assert_eq!(
mpp.currency(),
Some("0x20c0000000000000000000000000000000000000")
);
assert_eq!(
mpp.recipient(),
Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2")
);
assert_eq!(mpp.decimals(), 6);
}
#[cfg(feature = "tempo")]
#[test]
fn test_mpp_create_requires_secret_key() {
struct EnvGuard(Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.0 {
unsafe { std::env::set_var(SECRET_KEY_ENV_VAR, value) };
} else {
unsafe { std::env::remove_var(SECRET_KEY_ENV_VAR) };
}
}
}
let _guard = EnvGuard(std::env::var(SECRET_KEY_ENV_VAR).ok());
unsafe { std::env::remove_var(SECRET_KEY_ENV_VAR) };
let result = Mpp::create(tempo(TempoConfig {
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
}));
match result {
Ok(_) => panic!("missing secret key should fail creation"),
Err(err) => assert!(err.to_string().contains("Missing secret key")),
}
unsafe { std::env::set_var(SECRET_KEY_ENV_VAR, " ") };
let whitespace_env = Mpp::create(tempo(TempoConfig {
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
}));
match whitespace_env {
Ok(_) => panic!("whitespace-only env secret key should fail creation"),
Err(err) => assert!(err.to_string().contains("Missing secret key")),
}
let whitespace_builder = Mpp::create(
tempo(TempoConfig {
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
})
.secret_key(""),
);
match whitespace_builder {
Ok(_) => panic!("empty builder secret key should fail creation"),
Err(err) => assert!(err.to_string().contains("Missing secret key")),
}
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_dollar_amount() {
let mpp = create_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "charge");
assert_eq!(challenge.realm, "MPP Payment");
let request: ChargeRequest = challenge.request.decode().unwrap();
assert_eq!(request.amount, "100000");
assert_eq!(
request.currency,
"0x20c0000000000000000000000000000000000000"
);
assert_eq!(
request.recipient,
Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".to_string())
);
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_one_dollar() {
let mpp = create_test_mpp();
let challenge = mpp.charge("1").unwrap();
let request: ChargeRequest = challenge.request.decode().unwrap();
assert_eq!(request.amount, "1000000");
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_default_expires() {
let mpp = create_test_mpp();
let challenge = mpp.charge("1").unwrap();
assert!(challenge.expires.is_some());
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_requires_bound_currency() {
let payment = Mpp::new(MockMethod, "api.example.com", "secret");
let result = payment.charge("1.00");
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_credential_decodes_request() {
let request = ChargeRequest {
amount: "500000".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
..Default::default()
};
let encoded = crate::protocol::core::Base64UrlJson::from_typed(&request).unwrap();
let raw = encoded.raw().to_string();
let secret = "test-secret";
let id = {
#[cfg(feature = "tempo")]
{
crate::protocol::methods::tempo::generate_challenge_id(
secret,
"api.example.com",
"mock",
"charge",
&raw,
None,
None,
None,
)
}
#[cfg(not(feature = "tempo"))]
{
"test-id".to_string()
}
};
let echo = ChallengeEcho {
id,
realm: "api.example.com".into(),
method: "mock".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_raw(raw),
expires: None,
digest: None,
opaque: None,
};
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0x123"));
let payment = Mpp::new(SuccessReceiptMethod, "api.example.com", secret);
let receipt = payment.verify_credential(&credential).await.unwrap();
assert!(receipt.is_success());
assert_eq!(receipt.reference, "0xabc123");
}
#[cfg(feature = "tempo")]
#[test]
fn test_charge_with_options() {
let mpp = create_test_mpp();
let challenge = mpp
.charge_with_options(
"5.50",
ChargeOptions {
description: Some("API access fee"),
..Default::default()
},
)
.unwrap();
let request: ChargeRequest = challenge.request.decode().unwrap();
assert_eq!(request.amount, "5500000");
assert_eq!(challenge.description, Some("API access fee".to_string()));
}
#[derive(Clone)]
struct TempoSuccessMethod;
#[allow(clippy::manual_async_fn)]
impl ChargeMethod for TempoSuccessMethod {
fn method(&self) -> &str {
"tempo"
}
fn verify(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
async { Ok(Receipt::success("tempo", "0xtxhash")) }
}
}
#[cfg(feature = "tempo")]
fn create_hmac_test_mpp() -> Mpp<TempoSuccessMethod> {
Mpp {
method: TempoSuccessMethod,
session_method: None,
realm: "MPP Payment".into(),
secret_key: "test-secret".into(),
currency: Some("0x20c0000000000000000000000000000000000000".into()),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
}
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_verify_happy_path() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let receipt = mpp.verify_credential(&credential).await.unwrap();
assert!(receipt.is_success());
assert_eq!(receipt.reference, "0xtxhash");
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_tampered_request_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let mut echo = challenge.to_echo();
let tampered_request = ChargeRequest {
amount: "999999".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
..Default::default()
};
let encoded = crate::protocol::core::Base64UrlJson::from_typed(&tampered_request).unwrap();
echo.request = encoded;
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err());
assert!(
result.unwrap_err().message.contains("mismatch"),
"expected HMAC mismatch error"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_tampered_realm_ignored() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let mut echo = challenge.to_echo();
echo.realm = "evil.example.com".into();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(
result.is_ok(),
"echoed realm is ignored by server HMAC check"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_tampered_method_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let mut echo = challenge.to_echo();
echo.method = "evil-method".into();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err());
assert!(
result.unwrap_err().message.contains("mismatch"),
"expected HMAC mismatch error"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_tampered_intent_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let mut echo = challenge.to_echo();
echo.intent = "session".into();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err());
assert!(
result.unwrap_err().message.contains("mismatch"),
"expected HMAC mismatch error"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_hmac_charge_with_options_roundtrip() {
let mpp = create_hmac_test_mpp();
let challenge = mpp
.charge_with_options(
"2.50",
ChargeOptions {
description: Some("Premium access"),
fee_payer: true,
..Default::default()
},
)
.unwrap();
assert_eq!(challenge.description, Some("Premium access".to_string()));
let request: ChargeRequest = challenge.request.decode().unwrap();
assert_eq!(request.amount, "2500000");
let details = request.method_details.unwrap();
assert_eq!(details["feePayer"], serde_json::json!(true));
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let receipt = mpp.verify_credential(&credential).await.unwrap();
assert!(receipt.is_success());
}
#[derive(Clone)]
struct MockSessionMethod {
receipt: Receipt,
management_response: Option<serde_json::Value>,
}
impl MockSessionMethod {
fn success() -> Self {
Self {
receipt: Receipt::success("tempo", "0xsession_ref"),
management_response: None,
}
}
fn with_management_response(mut self, resp: serde_json::Value) -> Self {
self.management_response = Some(resp);
self
}
}
impl crate::protocol::traits::SessionMethod for MockSessionMethod {
fn method(&self) -> &str {
"tempo"
}
fn verify_session(
&self,
_credential: &PaymentCredential,
_request: &crate::protocol::intents::SessionRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
let receipt = self.receipt.clone();
async move { Ok(receipt) }
}
fn respond(
&self,
_credential: &PaymentCredential,
_receipt: &Receipt,
) -> Option<serde_json::Value> {
self.management_response.clone()
}
}
#[derive(Clone)]
struct MockFailingSessionMethod {
error: VerificationError,
}
impl MockFailingSessionMethod {
fn with_error(code: ErrorCode, message: &str) -> Self {
Self {
error: VerificationError::with_code(message, code),
}
}
}
impl crate::protocol::traits::SessionMethod for MockFailingSessionMethod {
fn method(&self) -> &str {
"tempo"
}
fn verify_session(
&self,
_credential: &PaymentCredential,
_request: &crate::protocol::intents::SessionRequest,
) -> impl Future<Output = std::result::Result<Receipt, VerificationError>> + Send {
let error = self.error.clone();
async move { Err(error) }
}
fn respond(
&self,
_credential: &PaymentCredential,
_receipt: &Receipt,
) -> Option<serde_json::Value> {
None
}
}
#[cfg(feature = "tempo")]
fn create_session_test_mpp() -> Mpp<TempoSuccessMethod, MockSessionMethod> {
Mpp {
method: TempoSuccessMethod,
session_method: Some(MockSessionMethod::success()),
realm: "MPP Payment".into(),
secret_key: "test-secret".into(),
currency: Some("0x20c0000000000000000000000000000000000000".into()),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
}
}
#[cfg(feature = "tempo")]
fn make_session_credential(
mpp: &Mpp<TempoSuccessMethod, MockSessionMethod>,
payload: serde_json::Value,
) -> PaymentCredential {
let challenge = mpp
.session_challenge(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
PaymentCredential::new(challenge.to_echo(), payload)
}
#[cfg(feature = "tempo")]
#[test]
fn test_session_challenge_roundtrip() {
let mpp = create_session_test_mpp();
let challenge = mpp
.session_challenge(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "session");
assert!(!challenge.id.is_empty());
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_happy_path() {
let mpp = create_session_test_mpp();
let credential = make_session_credential(
&mpp,
serde_json::json!({
"action": "voucher",
"channelId": "0xabc",
"cumulativeAmount": "5000",
"signature": "0xdef"
}),
);
let result = mpp.verify_session(&credential).await;
assert!(result.is_ok());
let session_result = result.unwrap();
assert!(session_result.receipt.is_success());
assert_eq!(session_result.receipt.reference, "0xsession_ref");
assert!(session_result.management_response.is_none());
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_management_response() {
let mock_session = MockSessionMethod::success()
.with_management_response(serde_json::json!({"status": "ok", "channelId": "0xabc"}));
let mpp: Mpp<TempoSuccessMethod, MockSessionMethod> = Mpp {
method: TempoSuccessMethod,
session_method: Some(mock_session),
realm: "MPP Payment".into(),
secret_key: "test-secret".into(),
currency: Some("0x20c0000000000000000000000000000000000000".into()),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
};
let challenge = mpp
.session_challenge(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let echo = challenge.to_echo();
let payload_json = serde_json::json!({
"action": "open",
"type": "transaction",
"channelId": "0xabc",
"transaction": "0x1234",
"cumulativeAmount": "5000",
"signature": "0xdef"
});
let credential = PaymentCredential::new(echo, payload_json);
let result = mpp.verify_session(&credential).await;
assert!(result.is_ok());
let session_result = result.unwrap();
assert!(session_result.management_response.is_some());
let mgmt = session_result.management_response.unwrap();
assert_eq!(mgmt["channelId"], "0xabc");
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_no_session_method() {
let mpp: Mpp<TempoSuccessMethod, MockSessionMethod> = Mpp {
method: TempoSuccessMethod,
session_method: None,
realm: "MPP Payment".into(),
secret_key: "test-secret".into(),
currency: Some("0x20c0000000000000000000000000000000000000".into()),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
};
let echo = ChallengeEcho {
id: "test".into(),
realm: "MPP Payment".into(),
method: "tempo".into(),
intent: "session".into(),
request: crate::protocol::core::Base64UrlJson::from_raw("eyJ0ZXN0IjoidmFsdWUifQ"),
expires: None,
digest: None,
opaque: None,
};
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0x123"));
let result = mpp.verify_session(&credential).await;
let err = result.unwrap_err();
assert!(err.message.contains("No session method"));
assert!(
err.code.is_none(),
"no-session-method should not have an error code"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_hmac_mismatch() {
let mpp = create_session_test_mpp();
let challenge = mpp
.session_challenge(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let mut echo = challenge.to_echo();
let tampered = crate::protocol::intents::SessionRequest {
amount: "999999".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
..Default::default()
};
let encoded = crate::protocol::core::Base64UrlJson::from_typed(&tampered).unwrap();
echo.request = encoded;
let payload_json = serde_json::json!({
"action": "voucher",
"channelId": "0xabc",
"cumulativeAmount": "5000",
"signature": "0xdef"
});
let credential = PaymentCredential::new(echo, payload_json);
let result = mpp.verify_session(&credential).await;
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::CredentialMismatch));
}
#[cfg(feature = "tempo")]
#[test]
fn test_session_challenge_with_details() {
let mpp = create_session_test_mpp();
let challenge = mpp
.session_challenge_with_details(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
super::super::SessionChallengeOptions {
unit_type: Some("second"),
suggested_deposit: Some("60000"),
fee_payer: true,
..Default::default()
},
)
.unwrap();
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "session");
let request: crate::protocol::intents::SessionRequest = challenge.request.decode().unwrap();
assert_eq!(request.amount, "1000");
assert_eq!(request.unit_type.as_deref(), Some("second"));
assert_eq!(request.suggested_deposit.as_deref(), Some("60000"));
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_method_returns_error() {
let mock_session = MockFailingSessionMethod::with_error(
ErrorCode::InsufficientBalance,
"channel balance exhausted",
);
let mpp: Mpp<TempoSuccessMethod, MockFailingSessionMethod> = Mpp {
method: TempoSuccessMethod,
session_method: Some(mock_session),
realm: "MPP Payment".into(),
secret_key: "test-secret".into(),
currency: Some("0x20c0000000000000000000000000000000000000".into()),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
decimals: DEFAULT_DECIMALS,
fee_payer: false,
chain_id: None,
};
let challenge = mpp
.session_challenge(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let echo = challenge.to_echo();
let payload_json = serde_json::json!({
"action": "voucher",
"channelId": "0xabc",
"cumulativeAmount": "5000",
"signature": "0xdef"
});
let credential = PaymentCredential::new(echo, payload_json);
let result = mpp.verify_session(&credential).await;
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::InsufficientBalance));
assert!(err.message.contains("channel balance exhausted"));
}
#[test]
fn test_session_verify_result_debug() {
let result = SessionVerifyResult {
receipt: Receipt::success("tempo", "0xref"),
management_response: Some(serde_json::json!({"status": "ok"})),
};
let debug = format!("{:?}", result);
assert!(debug.contains("0xref"));
assert!(debug.contains("status"));
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_expired_challenge_rejected() {
let mpp = create_hmac_test_mpp();
let past = (time::OffsetDateTime::now_utc() - time::Duration::minutes(10))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let challenge = mpp
.charge_with_options(
"0.10",
crate::server::ChargeOptions {
expires: Some(&past),
..Default::default()
},
)
.unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::Expired));
assert!(
err.message.contains("expired"),
"expected expiry error, got: {}",
err.message
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_non_expired_challenge_accepted() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
assert!(
challenge.expires.is_some(),
"charge should have default expires"
);
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_ok(), "non-expired challenge should be accepted");
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_malformed_expires_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp
.charge_with_options(
"0.10",
crate::server::ChargeOptions {
expires: Some("not-a-timestamp"),
..Default::default()
},
)
.unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err());
assert!(
result.unwrap_err().message.contains("Invalid expires"),
"expected invalid expires error"
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_credential_with_wrong_amount_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let wrong_request = ChargeRequest {
amount: "999999999".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
..Default::default()
};
let result = mpp
.verify_credential_with_expected_request(&credential, &wrong_request)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::CredentialMismatch));
assert!(
err.message.contains("Amount mismatch"),
"expected amount mismatch error, got: {}",
err.message
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_credential_with_correct_request_accepted() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let expected_request = ChargeRequest {
amount: "100000".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
..Default::default()
};
let result = mpp
.verify_credential_with_expected_request(&credential, &expected_request)
.await;
assert!(result.is_ok(), "correct request should be accepted");
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_credential_with_wrong_recipient_rejected() {
let mpp = create_hmac_test_mpp();
let challenge = mpp.charge("0.10").unwrap();
let echo = challenge.to_echo();
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0xdeadbeef"));
let wrong_recipient = ChargeRequest {
amount: "100000".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x0000000000000000000000000000000000000001".into()),
..Default::default()
};
let result = mpp
.verify_credential_with_expected_request(&credential, &wrong_recipient)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::CredentialMismatch));
assert!(
err.message.contains("Recipient mismatch"),
"expected recipient mismatch error, got: {}",
err.message
);
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_verify_session_expired_challenge_rejected() {
let mpp = create_session_test_mpp();
let past = (time::OffsetDateTime::now_utc() - time::Duration::minutes(10))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let challenge = mpp
.session_challenge_with_details(
"1000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
crate::server::SessionChallengeOptions {
expires: Some(&past),
..Default::default()
},
)
.unwrap();
let echo = challenge.to_echo();
let payload_json = serde_json::json!({
"action": "voucher",
"channelId": "0xabc",
"cumulativeAmount": "5000",
"signature": "0xdef"
});
let credential = PaymentCredential::new(echo, payload_json);
let result = mpp.verify_session(&credential).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, Some(ErrorCode::Expired));
}
#[cfg(feature = "stripe")]
fn test_stripe_mpp() -> Mpp<crate::protocol::methods::stripe::method::ChargeMethod> {
use crate::server::{stripe, StripeConfig};
Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "test-net",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.secret_key("test-hmac-secret"),
)
.expect("failed to create stripe mpp")
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_challenge_has_method_details() {
let mpp = test_stripe_mpp();
let challenge = mpp.stripe_charge("1.00").unwrap();
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
let details = &request["methodDetails"];
assert_eq!(details["networkId"], "test-net");
assert_eq!(details["paymentMethodTypes"], serde_json::json!(["card"]));
assert_eq!(challenge.method.as_str(), "stripe");
assert_eq!(challenge.intent.as_str(), "charge");
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_charge_with_options_description() {
use crate::server::StripeChargeOptions;
let mpp = test_stripe_mpp();
let challenge = mpp
.stripe_charge_with_options(
"0.50",
StripeChargeOptions {
description: Some("test desc"),
..Default::default()
},
)
.unwrap();
assert_eq!(challenge.description, Some("test desc".to_string()));
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
assert_eq!(request["description"], "test desc");
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_charge_with_options_external_id() {
use crate::server::StripeChargeOptions;
let mpp = test_stripe_mpp();
let challenge = mpp
.stripe_charge_with_options(
"0.50",
StripeChargeOptions {
external_id: Some("order-42"),
..Default::default()
},
)
.unwrap();
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
assert_eq!(request["externalId"], "order-42");
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_charge_with_options_metadata() {
use crate::server::StripeChargeOptions;
let mpp = test_stripe_mpp();
let mut metadata = std::collections::HashMap::new();
metadata.insert("key1".to_string(), "val1".to_string());
let challenge = mpp
.stripe_charge_with_options(
"0.50",
StripeChargeOptions {
metadata: Some(&metadata),
..Default::default()
},
)
.unwrap();
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
assert_eq!(request["methodDetails"]["metadata"]["key1"], "val1");
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_charge_with_options_custom_expires() {
use crate::server::StripeChargeOptions;
let mpp = test_stripe_mpp();
let challenge = mpp
.stripe_charge_with_options(
"0.50",
StripeChargeOptions {
expires: Some("2099-01-01T00:00:00Z"),
..Default::default()
},
)
.unwrap();
assert_eq!(challenge.expires, Some("2099-01-01T00:00:00Z".to_string()));
}
#[cfg(feature = "stripe")]
#[test]
fn test_stripe_charge_delegates_to_with_options() {
let mpp = test_stripe_mpp();
let challenge = mpp.stripe_charge("0.10").unwrap();
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
assert!(request["methodDetails"].is_object());
assert!(challenge.description.is_none());
}
}