diff --git a/README.md b/README.md index 21b68f6..ba94dd1 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Create a new payment intent. | `amount` | string | ✅ | Any positive number | | `asset` | string | ✅ | `XLM` or `USDC` | | `merchant_id` | string | ❌ | Any string | -| `webhook_url` | string | ❌ | Valid HTTPS URL | +| `webhook_url` | string | ❌ | Valid HTTPS URL (HTTP permitted only in testnet/development) | **Headers** diff --git a/openapi.yaml b/openapi.yaml index 389422d..4ceed76 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -264,6 +264,7 @@ components: webhook_url: type: string format: uri + description: Webhook endpoint URL. Must be HTTPS in production. example: https://yourapp.com/webhooks/stellar ListPaymentsResponse: diff --git a/src/api/payments.rs b/src/api/payments.rs index 6475d6b..77aa1b6 100644 --- a/src/api/payments.rs +++ b/src/api/payments.rs @@ -127,10 +127,21 @@ pub async fn create( )); } if let Some(url) = &body.webhook_url { - if !(url.starts_with("http://") || url.starts_with("https://")) { + let parsed_url = reqwest::Url::parse(url).map_err(|_| { + AppError::bad_request("invalid_webhook_url", "webhook_url is not a valid URL") + })?; + + if state.config.network == "public" { + if parsed_url.scheme() != "https" { + return Err(AppError::bad_request( + "invalid_webhook_url", + "webhook_url must be an HTTPS URL on public network", + )); + } + } else if parsed_url.scheme() != "https" && parsed_url.scheme() != "http" { return Err(AppError::bad_request( "invalid_webhook_url", - "webhook_url must be an http(s) URL", + "webhook_url must be an HTTP or HTTPS URL", )); } } diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 07a44fa..97ac6e3 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -410,13 +410,89 @@ async fn test_asset_is_case_insensitive() { } #[tokio::test] -async fn test_reject_bad_webhook_url() { +async fn test_webhook_url_https_accepted_on_testnet() { let server = test_server().await; let key = provision_merchant(&server).await; let res = server .post("/payments") .add_header("Authorization", format!("Bearer {key}")) - .json(&json!({ "amount": "1", "asset": "XLM", "webhook_url": "ftp://x" })) + .json( + &json!({ "amount": "1", "asset": "XLM", "webhook_url": "https://example.com/webhook" }), + ) + .await; + res.assert_status(StatusCode::CREATED); +} + +#[tokio::test] +async fn test_webhook_url_http_accepted_on_testnet() { + let server = test_server().await; + let key = provision_merchant(&server).await; + let res = server + .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) + .json( + &json!({ "amount": "1", "asset": "XLM", "webhook_url": "http://example.com/webhook" }), + ) + .await; + res.assert_status(StatusCode::CREATED); +} + +#[tokio::test] +async fn test_webhook_url_http_rejected_on_public_network() { + let mut cfg = make_config(); + cfg.network = "public".into(); + let (server, _db) = server_with_config(cfg).await; + let key = provision_merchant(&server).await; + let res = server + .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) + .json( + &json!({ "amount": "1", "asset": "XLM", "webhook_url": "http://example.com/webhook" }), + ) + .await; + res.assert_status(StatusCode::BAD_REQUEST); + let body: Value = res.json(); + assert_eq!(body["code"], "invalid_webhook_url"); + assert!(body["error"] + .as_str() + .unwrap() + .contains("must be an HTTPS URL on public network")); +} + +#[tokio::test] +async fn test_webhook_url_https_accepted_on_public_network() { + let mut cfg = make_config(); + cfg.network = "public".into(); + let (server, _db) = server_with_config(cfg).await; + let key = provision_merchant(&server).await; + let res = server + .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) + .json( + &json!({ "amount": "1", "asset": "XLM", "webhook_url": "https://example.com/webhook" }), + ) + .await; + res.assert_status(StatusCode::CREATED); +} + +#[tokio::test] +async fn test_webhook_url_invalid_rejected() { + let server = test_server().await; + let key = provision_merchant(&server).await; + + // ftp scheme + let res = server + .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) + .json(&json!({ "amount": "1", "asset": "XLM", "webhook_url": "ftp://example.com" })) + .await; + res.assert_status(StatusCode::BAD_REQUEST); + + // malformed string + let res = server + .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) + .json(&json!({ "amount": "1", "asset": "XLM", "webhook_url": "not-a-url" })) .await; res.assert_status(StatusCode::BAD_REQUEST); }