#![cfg(feature = "integration-stripe")]
use std::sync::Arc;
use axum::extract::Form;
use axum::{routing::get, Json, Router};
use mpp::client::{Fetch, StripeProvider};
use mpp::protocol::core::PaymentCredential;
use mpp::protocol::methods::stripe::{CreateTokenResult, StripeCredentialPayload};
use mpp::server::axum::{ChargeChallenger, ChargeConfig, MppCharge};
use mpp::server::{stripe, Mpp, StripeChargeOptions, StripeConfig};
use reqwest::Client;
async fn start_mock_stripe() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route(
"/v1/payment_intents",
axum::routing::post(mock_create_payment_intent),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind mock stripe");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("mock stripe error");
});
(url, handle)
}
async fn mock_create_payment_intent(
Form(params): Form<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
assert!(
params.contains_key("shared_payment_granted_token"),
"missing shared_payment_granted_token"
);
assert!(params.contains_key("amount"), "missing amount");
assert!(params.contains_key("currency"), "missing currency");
assert_eq!(
params.get("confirm").map(|s| s.as_str()),
Some("true"),
"confirm must be true"
);
assert_eq!(
params
.get("automatic_payment_methods[enabled]")
.map(|s| s.as_str()),
Some("true"),
"automatic_payment_methods must be enabled"
);
Json(serde_json::json!({
"id": format!("pi_mock_{}", params.get("shared_payment_granted_token").unwrap()),
"status": "succeeded",
"amount": params["amount"],
"currency": params["currency"],
}))
}
async fn start_mock_stripe_requires_action() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route(
"/v1/payment_intents",
axum::routing::post(|| async {
Json(serde_json::json!({
"id": "pi_requires_action",
"status": "requires_action",
}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(url, handle)
}
async fn start_server(
mpp: Arc<Mpp<mpp::protocol::methods::stripe::method::ChargeMethod>>,
) -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new()
.route("/health", get(health))
.route("/paid", get(paid))
.route("/paid-premium", get(paid_premium))
.with_state(mpp);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server error");
});
(url, handle)
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn start_mock_stripe_replayed() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route(
"/v1/payment_intents",
axum::routing::post(|| async {
(
[(
axum::http::header::HeaderName::from_static("idempotent-replayed"),
axum::http::header::HeaderValue::from_static("true"),
)],
Json(serde_json::json!({
"id": "pi_replayed",
"status": "succeeded",
})),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(url, handle)
}
async fn start_mock_stripe_error() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().route(
"/v1/payment_intents",
axum::routing::post(|| async {
(
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": "Invalid payment token",
"type": "invalid_request_error",
"code": "resource_missing"
}
})),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(url, handle)
}
async fn paid(
axum::extract::State(mpp): axum::extract::State<
Arc<Mpp<mpp::protocol::methods::stripe::method::ChargeMethod>>,
>,
req: axum::extract::Request,
) -> axum::response::Response {
use axum::response::IntoResponse;
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let issue_challenge = || {
let challenge = mpp.stripe_charge("0.10").expect("challenge creation");
let www_auth = challenge.to_header().expect("format challenge");
let mut resp = axum::http::StatusCode::PAYMENT_REQUIRED.into_response();
resp.headers_mut().insert(
"www-authenticate",
www_auth.parse().expect("www-auth header value"),
);
resp
};
match auth_header {
Some(auth) => {
let credential = match mpp::parse_authorization(&auth) {
Ok(c) => c,
Err(_) => return issue_challenge(),
};
match mpp.verify_credential(&credential).await {
Ok(receipt) => {
let body = serde_json::json!({ "message": "paid content" });
let mut resp = axum::response::Json(body).into_response();
let receipt_hdr = receipt.to_header().expect("format receipt");
resp.headers_mut().insert(
"payment-receipt",
receipt_hdr.parse().expect("receipt header value"),
);
resp
}
Err(_) => issue_challenge(),
}
}
None => issue_challenge(),
}
}
async fn paid_premium(
axum::extract::State(mpp): axum::extract::State<
Arc<Mpp<mpp::protocol::methods::stripe::method::ChargeMethod>>,
>,
req: axum::extract::Request,
) -> axum::response::Response {
use axum::response::IntoResponse;
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let issue_challenge = || {
let challenge = mpp
.stripe_charge_with_options(
"1.00",
StripeChargeOptions {
description: Some("Premium content"),
external_id: Some("premium-001"),
..Default::default()
},
)
.expect("challenge creation");
let www_auth = challenge.to_header().expect("format challenge");
let mut resp = axum::http::StatusCode::PAYMENT_REQUIRED.into_response();
resp.headers_mut().insert(
"www-authenticate",
www_auth.parse().expect("www-auth header value"),
);
resp
};
match auth_header {
Some(auth) => match mpp::parse_authorization(&auth) {
Ok(credential) => match mpp.verify_credential(&credential).await {
Ok(receipt) => {
let body = serde_json::json!({ "message": "premium content" });
let mut resp = axum::response::Json(body).into_response();
let receipt_hdr = receipt.to_header().expect("format receipt");
resp.headers_mut().insert(
"payment-receipt",
receipt_hdr.parse().expect("receipt header value"),
);
resp
}
Err(_) => issue_challenge(),
},
Err(_) => issue_challenge(),
},
None => issue_challenge(),
}
}
struct TenCents;
impl ChargeConfig for TenCents {
fn amount() -> &'static str {
"0.10"
}
}
async fn paid_extractor(charge: MppCharge<TenCents>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"message": "paid via extractor",
"method": charge.receipt.method.as_str(),
}))
}
async fn start_server_with_extractor(
mpp: Arc<Mpp<mpp::protocol::methods::stripe::method::ChargeMethod>>,
) -> (String, tokio::task::JoinHandle<()>) {
let challenger: Arc<dyn ChargeChallenger> = mpp;
let app = Router::new()
.route("/paid-extractor", get(paid_extractor))
.with_state(challenger);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server error");
});
(url, handle)
}
#[tokio::test]
async fn test_e2e_stripe_charge() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock_key",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-hmac-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let provider = StripeProvider::new(|_params| {
Box::pin(async move {
Ok(CreateTokenResult::from(
"spt_mock_test_token_123".to_string(),
))
})
});
let resp = Client::new()
.get(format!("{url}/paid"))
.send_with_payment(&provider)
.await
.expect("stripe payment failed");
assert_eq!(resp.status(), 200);
let receipt_hdr = resp
.headers()
.get("payment-receipt")
.expect("missing Payment-Receipt header")
.to_str()
.unwrap();
let receipt = mpp::parse_receipt(receipt_hdr).expect("failed to parse receipt");
assert_eq!(receipt.method.as_str(), "stripe");
assert_eq!(receipt.status, mpp::ReceiptStatus::Success);
assert!(
receipt.reference.starts_with("pi_mock_"),
"receipt reference should be a mock PI id, got: {}",
receipt.reference
);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["message"], "paid content");
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_402_challenge_format() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "test-network",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let resp = Client::new()
.get(format!("{url}/paid"))
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), 402);
let www_auth = resp
.headers()
.get("www-authenticate")
.expect("missing WWW-Authenticate header")
.to_str()
.unwrap();
assert!(
www_auth.starts_with("Payment "),
"WWW-Authenticate should start with 'Payment ', got: {www_auth}"
);
let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge");
assert_eq!(challenge.method.as_str(), "stripe");
assert_eq!(challenge.intent.as_str(), "charge");
let request: serde_json::Value = challenge
.request
.decode_value()
.expect("failed to decode request");
assert!(request["amount"].is_string(), "amount should be a string");
assert_eq!(request["currency"], "usd");
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_health_no_payment() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let resp = Client::new()
.get(format!("{url}/health"))
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_requires_action_rejected() {
let (stripe_url, stripe_handle) = start_mock_stripe_requires_action().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let provider = StripeProvider::new(|_params| {
Box::pin(async move {
Ok(CreateTokenResult::from(
"spt_will_require_action".to_string(),
))
})
});
let resp = Client::new()
.get(format!("{url}/paid"))
.send_with_payment(&provider)
.await;
if let Ok(r) = resp {
assert_eq!(
r.status(),
402,
"should get 402 when Stripe requires action"
);
}
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_challenge_contains_method_details() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "test-network-id",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let resp = Client::new()
.get(format!("{url}/paid"))
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), 402);
let www_auth = resp
.headers()
.get("www-authenticate")
.expect("missing WWW-Authenticate header")
.to_str()
.unwrap();
let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge");
let request: serde_json::Value = challenge
.request
.decode_value()
.expect("failed to decode request");
assert_eq!(
request["methodDetails"]["networkId"], "test-network-id",
"methodDetails.networkId should match configured network_id"
);
assert_eq!(
request["methodDetails"]["paymentMethodTypes"],
serde_json::json!(["card"]),
"methodDetails.paymentMethodTypes should be [\"card\"]"
);
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_e2e_stripe_charge_with_description() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "test-net",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let resp = Client::new()
.get(format!("{url}/paid-premium"))
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), 402);
let www_auth = resp
.headers()
.get("www-authenticate")
.expect("missing header")
.to_str()
.unwrap();
assert!(
www_auth.contains("description="),
"challenge should contain description"
);
let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse");
assert_eq!(challenge.description.as_deref(), Some("Premium content"));
let request: serde_json::Value = challenge
.request
.decode_value()
.expect("failed to decode request");
assert_eq!(request["description"], "Premium content");
assert_eq!(request["externalId"], "premium-001");
let provider = StripeProvider::new(|_params| {
Box::pin(async move { Ok(CreateTokenResult::from("spt_premium_token".to_string())) })
});
let resp = Client::new()
.get(format!("{url}/paid-premium"))
.send_with_payment(&provider)
.await
.expect("payment failed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["message"], "premium content");
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_error_body_parsing() {
let (stripe_url, stripe_handle) = start_mock_stripe_error().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("create mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let provider = StripeProvider::new(|_| {
Box::pin(async move { Ok(CreateTokenResult::from("spt_bad_token".to_string())) })
});
let resp = Client::new()
.get(format!("{url}/paid"))
.send_with_payment(&provider)
.await;
if let Ok(r) = resp {
assert_eq!(r.status(), 402, "should get 402 when Stripe returns error");
}
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_rejects_replayed_credential() {
let (stripe_url, stripe_handle) = start_mock_stripe_replayed().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("create mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server(mpp).await;
let provider = StripeProvider::new(|_| {
Box::pin(async move { Ok(CreateTokenResult::from("spt_replayed_token".to_string())) })
});
let resp = Client::new()
.get(format!("{url}/paid"))
.send_with_payment(&provider)
.await;
if let Ok(r) = resp {
assert_eq!(
r.status(),
402,
"should get 402 when Stripe returns idempotent-replayed"
);
}
handle.abort();
stripe_handle.abort();
}
#[tokio::test]
async fn test_stripe_charge_via_mpp_charge_extractor() {
let (stripe_url, stripe_handle) = start_mock_stripe().await;
let mpp = Mpp::create_stripe(
stripe(StripeConfig {
secret_key: "sk_test_mock",
network_id: "extractor-net",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.stripe_api_base(&stripe_url)
.secret_key("test-secret"),
)
.expect("failed to create Mpp");
let mpp = Arc::new(mpp);
let (url, handle) = start_server_with_extractor(mpp).await;
let resp = Client::new()
.get(format!("{url}/paid-extractor"))
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), 402);
let www_auth = resp
.headers()
.get("www-authenticate")
.expect("missing WWW-Authenticate header")
.to_str()
.unwrap();
let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge");
assert_eq!(challenge.method.as_str(), "stripe");
assert_eq!(challenge.intent.as_str(), "charge");
let provider = StripeProvider::new(|_params| {
Box::pin(async move { Ok(CreateTokenResult::from("spt_extractor_token".to_string())) })
});
let resp = Client::new()
.get(format!("{url}/paid-extractor"))
.send_with_payment(&provider)
.await
.expect("payment failed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["message"], "paid via extractor");
assert_eq!(body["method"], "stripe");
handle.abort();
stripe_handle.abort();
}
fn stripe_secret_key() -> Option<String> {
std::env::var("STRIPE_SECRET_KEY")
.ok()
.filter(|s| !s.is_empty())
}
async fn create_test_spt(
secret_key: &str,
amount: &str,
currency: &str,
network_id: Option<&str>,
expires_at: u64,
) -> Result<String, String> {
let base_params = vec![
("payment_method".to_string(), "pm_card_visa".to_string()),
("usage_limits[currency]".to_string(), currency.to_string()),
("usage_limits[max_amount]".to_string(), amount.to_string()),
(
"usage_limits[expires_at]".to_string(),
expires_at.to_string(),
),
];
let mut params = base_params.clone();
if let Some(nid) = network_id {
params.push(("seller_details[network_id]".to_string(), nid.to_string()));
}
let auth = format!(
"Basic {}",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
format!("{secret_key}:")
)
);
let client = Client::new();
let url = "https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens";
let response = client
.post(url)
.header("Authorization", &auth)
.form(¶ms)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let response = if !response.status().is_success() && network_id.is_some() {
let body = response.text().await.unwrap_or_default();
if body.contains("Received unknown parameter") {
client
.post(url)
.header("Authorization", &auth)
.form(&base_params)
.send()
.await
.map_err(|e| format!("fallback request failed: {e}"))?
} else {
return Err(format!("Stripe SPT creation failed: {body}"));
}
} else {
response
};
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Stripe SPT creation failed: {body}"));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| format!("parse error: {e}"))?;
body["id"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "missing id in SPT response".to_string())
}
fn create_live_mpp(secret_key: &str) -> Mpp<mpp::protocol::methods::stripe::method::ChargeMethod> {
Mpp::create_stripe(
stripe(StripeConfig {
secret_key,
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
})
.secret_key("live-test-hmac-secret"),
)
.expect("failed to create Mpp")
}
#[tokio::test]
async fn test_live_stripe_charge_success() {
let Some(sk) = stripe_secret_key() else {
eprintln!("STRIPE_SECRET_KEY not set, skipping");
return;
};
let mpp = create_live_mpp(&sk);
let challenge = mpp.stripe_charge("0.50").expect("challenge creation");
let request: serde_json::Value = challenge.request.decode_value().expect("decode request");
let amount = request["amount"].as_str().expect("amount");
assert_eq!(amount, "50");
let expires_at = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs())
+ 3600;
let spt = create_test_spt(&sk, amount, "usd", Some("internal"), expires_at)
.await
.expect("SPT creation failed");
assert!(spt.starts_with("spt_"), "got: {spt}");
let credential = PaymentCredential::new(
challenge.to_echo(),
StripeCredentialPayload {
spt,
external_id: None,
},
);
let receipt = mpp
.verify_credential(&credential)
.await
.expect("verification failed");
assert!(receipt.is_success());
assert_eq!(receipt.method.as_str(), "stripe");
assert!(receipt.reference.starts_with("pi_"));
}
#[tokio::test]
async fn test_live_stripe_invalid_spt_rejected() {
let Some(sk) = stripe_secret_key() else {
eprintln!("STRIPE_SECRET_KEY not set, skipping");
return;
};
let mpp = create_live_mpp(&sk);
let challenge = mpp.stripe_charge("0.10").expect("challenge creation");
let credential = PaymentCredential::new(
challenge.to_echo(),
StripeCredentialPayload {
spt: "spt_invalid_does_not_exist".to_string(),
external_id: None,
},
);
let result = mpp.verify_credential(&credential).await;
assert!(result.is_err(), "invalid SPT should fail verification");
}