From 291686a1febff06ff672adf303dac8992948a8f4 Mon Sep 17 00:00:00 2001 From: Nexha-dev Date: Tue, 23 Jun 2026 12:55:23 +0000 Subject: [PATCH] feat: merchant API key auth for POST /payments and GET /payments - Add merchants table (id, api_key_hash) to migrate() - POST /merchants provisions a merchant and returns a one-time raw key - auth_middleware validates Bearer token via SHA-256 hash lookup - Derive merchant_id from the authenticated key; removed free-text field - Scope GET /payments list to authenticated merchant only - GET /payments/:id remains public (poll by payment id without key) - Add tests: 401 on missing/invalid key, merchant list isolation, idempotency key scoping per merchant Closes #9 --- migrations/0002_add_merchants.sql | 8 ++ src/api/mod.rs | 88 +++++++++++- src/api/payments.rs | 18 +-- src/db.rs | 76 +++++++++-- tests/api_tests.rs | 218 +++++++++++++++++++++++++----- 5 files changed, 355 insertions(+), 53 deletions(-) create mode 100644 migrations/0002_add_merchants.sql diff --git a/migrations/0002_add_merchants.sql b/migrations/0002_add_merchants.sql new file mode 100644 index 0000000..d706233 --- /dev/null +++ b/migrations/0002_add_merchants.sql @@ -0,0 +1,8 @@ +-- Merchants are provisioned via POST /merchants. +-- api_key_hash stores SHA-256(raw_key) in hex so the plaintext key is never +-- persisted; the raw key is returned once at creation time and never again. +CREATE TABLE IF NOT EXISTS merchants ( + id TEXT PRIMARY KEY, + api_key_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); diff --git a/src/api/mod.rs b/src/api/mod.rs index 8f1db42..54b5f87 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,7 +5,7 @@ use axum::{ middleware::{self, Next}, response::IntoResponse, routing::{get, post}, - Json, + Extension, Json, }; use serde_json::json; use std::net::SocketAddr; @@ -22,6 +22,10 @@ mod payments; /// Reject request bodies larger than this (256 KiB) before they hit a handler. const MAX_BODY_BYTES: usize = 256 * 1024; +/// The authenticated merchant ID injected by the auth middleware. +#[derive(Clone)] +pub struct AuthenticatedMerchant(pub String); + pub fn router(state: Arc) -> axum::Router { let cors = build_cors(&state.config); let rate_limit_rps = state.config.rate_limit_requests_per_sec; @@ -30,9 +34,21 @@ pub fn router(state: Arc) -> axum::Router { .route("/", get(|| async { "StellarGate API v0.1.0" })) .route("/health", get(health)) .route("/ready", get(ready)) + // Merchant provisioning — returns a one-time plaintext API key. + .route("/merchants", post(provision_merchant)) .nest("/payments", { - axum::Router::new() + // Auth middleware only on the write + list routes; the per-payment + // status and webhook endpoints stay public (anyone with the id can + // poll or inspect). + let authed = axum::Router::new() .route("/", post(payments::create).get(payments::list)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )); + + axum::Router::new() + .merge(authed) .route("/:id", get(payments::get_by_id)) .route("/:id/webhooks", get(payments::list_webhooks)) .route( @@ -54,6 +70,74 @@ pub fn router(state: Arc) -> axum::Router { .with_state(state) } +async fn auth_middleware( + State(state): State>, + mut req: Request, + next: Next, +) -> axum::response::Response { + let raw_key = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(str::to_string); + + let Some(key) = raw_key else { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "missing or invalid Authorization header", "code": "unauthorized" })), + ) + .into_response(); + }; + + match db::find_merchant_by_key(&state.pool, &key).await { + Ok(Some(merchant_id)) => { + req.extensions_mut() + .insert(AuthenticatedMerchant(merchant_id)); + next.run(req).await + } + Ok(None) => ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid API key", "code": "unauthorized" })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal server error", "code": "internal_error" })), + ) + .into_response(), + } +} + +/// `POST /merchants` — provision a new merchant and return its API key once. +/// In production this endpoint should be protected (e.g., by an admin secret +/// or removed entirely in favour of an offline provisioning script). +async fn provision_merchant( + State(state): State>, +) -> Result)> { + let merchant_id = uuid::Uuid::new_v4().to_string(); + let raw_key = uuid::Uuid::new_v4().to_string(); + + db::create_merchant(&state.pool, &merchant_id, &raw_key) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal server error", "code": "internal_error" })), + ) + })?; + + Ok(( + StatusCode::CREATED, + Json(json!({ + "merchant_id": merchant_id, + "api_key": raw_key, + })), + )) +} + async fn rate_limit_middleware( addr: SocketAddr, rate_limit_rps: u32, diff --git a/src/api/payments.rs b/src/api/payments.rs index 72ea849..0cbf433 100644 --- a/src/api/payments.rs +++ b/src/api/payments.rs @@ -1,7 +1,7 @@ -use crate::{db, money, AppState}; +use crate::{api::AuthenticatedMerchant, db, money, AppState}; use axum::{ async_trait, - extract::{FromRequest, Path, Query, Request, State}, + extract::{Extension, FromRequest, Path, Query, Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, @@ -94,7 +94,6 @@ pub struct CreatePaymentRequest { pub amount: String, #[serde(default = "default_asset")] pub asset: String, - pub merchant_id: Option, pub webhook_url: Option, } @@ -104,6 +103,7 @@ fn default_asset() -> String { pub async fn create( State(state): State>, + Extension(AuthenticatedMerchant(merchant_id)): Extension, headers: HeaderMap, JsonBody(body): JsonBody, ) -> Result<(StatusCode, Json), AppError> { @@ -135,8 +135,6 @@ pub async fn create( } } - let merchant_id = body.merchant_id.as_deref().unwrap_or("anonymous"); - // An optional Idempotency-Key lets a client safely retry a create after a // network blip without minting a duplicate intent. Keys are scoped per // merchant; an empty header value is treated as absent. @@ -150,7 +148,7 @@ pub async fn create( // payment with 200 instead of creating a new one. if let Some(key) = idempotency_key { if let Some(existing_id) = - db::find_payment_id_by_idempotency_key(&state.pool, merchant_id, key).await? + db::find_payment_id_by_idempotency_key(&state.pool, &merchant_id, key).await? { if let Some(payment) = db::get_payment(&state.pool, &existing_id).await? { return Ok((StatusCode::OK, Json(to_json(&payment)))); @@ -165,7 +163,7 @@ pub async fn create( &state.pool, db::NewPayment { id: &id, - merchant_id, + merchant_id: &merchant_id, destination_address: &state.config.gateway_public, memo: &memo, amount: &body.amount, @@ -180,7 +178,7 @@ pub async fn create( // `save_idempotency_key` returns the canonical id; return that payment so // both retries converge on a single intent. if let Some(key) = idempotency_key { - let canonical_id = db::save_idempotency_key(&state.pool, merchant_id, key, &id).await?; + let canonical_id = db::save_idempotency_key(&state.pool, &merchant_id, key, &id).await?; if canonical_id != id { if let Some(payment) = db::get_payment(&state.pool, &canonical_id).await? { return Ok((StatusCode::OK, Json(to_json(&payment)))); @@ -219,6 +217,7 @@ const VALID_STATUSES: [&str; 4] = ["pending", "completed", "failed", "expired"]; pub async fn list( State(state): State>, + Extension(AuthenticatedMerchant(merchant_id)): Extension, Query(q): Query, ) -> Result, AppError> { if let Some(s) = &q.status { @@ -243,6 +242,7 @@ pub async fn list( let payments = db::list_payments_keyset( &state.pool, + &merchant_id, q.status.as_deref(), limit, Some((&cursor_ts, &cursor_id)), @@ -264,7 +264,7 @@ pub async fn list( // Legacy offset pagination — kept for backward compatibility. let offset = q.offset.unwrap_or(0).max(0); let (payments, total) = - db::list_payments(&state.pool, q.status.as_deref(), limit, offset).await?; + db::list_payments(&state.pool, &merchant_id, q.status.as_deref(), limit, offset).await?; // Provide next_cursor to ease migration to keyset pagination. let next_cursor = payments.last().map(|p| encode_cursor(&p.created_at, &p.id)); diff --git a/src/db.rs b/src/db.rs index 8e60689..602f979 100644 --- a/src/db.rs +++ b/src/db.rs @@ -108,6 +108,19 @@ pub async fn migrate(pool: &Db) -> Result<()> { .execute(pool) .await?; + // Merchants are provisioned via POST /merchants. The raw API key is never + // stored; only its SHA-256 hex digest is persisted so a DB breach does not + // expose live credentials. + sqlx::query( + "CREATE TABLE IF NOT EXISTS merchants ( + id TEXT PRIMARY KEY, + api_key_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )", + ) + .execute(pool) + .await?; + // Idempotency keys for payment creation. A key is unique per merchant and // maps to the payment id minted for the first request that used it, so a // client retrying after a network blip gets the original payment back @@ -276,6 +289,7 @@ pub async fn get_payment(pool: &Db, id: &str) -> Result> { pub async fn list_payments( pool: &Db, + merchant_id: &str, status: Option<&str>, limit: i64, offset: i64, @@ -284,15 +298,17 @@ pub async fn list_payments( let rows = sqlx::query( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at - FROM payments WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + FROM payments WHERE merchant_id = ? AND status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", ) + .bind(merchant_id) .bind(s) .bind(limit) .bind(offset) .fetch_all(pool) .await?; - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payments WHERE status = ?") + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payments WHERE merchant_id = ? AND status = ?") + .bind(merchant_id) .bind(s) .fetch_one(pool) .await?; @@ -302,14 +318,16 @@ pub async fn list_payments( let rows = sqlx::query( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at - FROM payments ORDER BY created_at DESC LIMIT ? OFFSET ?", + FROM payments WHERE merchant_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", ) + .bind(merchant_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payments") + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payments WHERE merchant_id = ?") + .bind(merchant_id) .fetch_one(pool) .await?; @@ -321,6 +339,7 @@ pub async fn list_payments( pub async fn list_payments_keyset( pool: &Db, + merchant_id: &str, status: Option<&str>, limit: i64, cursor: Option<(&str, &str)>, @@ -330,8 +349,9 @@ pub async fn list_payments_keyset( sqlx::query( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at - FROM payments ORDER BY created_at DESC, id DESC LIMIT ?", + FROM payments WHERE merchant_id = ? ORDER BY created_at DESC, id DESC LIMIT ?", ) + .bind(merchant_id) .bind(limit) .fetch_all(pool) .await? @@ -342,9 +362,10 @@ pub async fn list_payments_keyset( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at FROM payments - WHERE (created_at < ? OR (created_at = ? AND id < ?)) + WHERE merchant_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?", ) + .bind(merchant_id) .bind(ts) .bind(ts) .bind(cid) @@ -357,8 +378,9 @@ pub async fn list_payments_keyset( sqlx::query( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at - FROM payments WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?", + FROM payments WHERE merchant_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT ?", ) + .bind(merchant_id) .bind(s) .bind(limit) .fetch_all(pool) @@ -370,9 +392,10 @@ pub async fn list_payments_keyset( "SELECT id, merchant_id, destination_address, memo, amount, asset, status, webhook_url, tx_hash, paid_amount, created_at, updated_at, expires_at FROM payments - WHERE status = ? AND (created_at < ? OR (created_at = ? AND id < ?)) + WHERE merchant_id = ? AND status = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?", ) + .bind(merchant_id) .bind(s) .bind(ts) .bind(ts) @@ -608,6 +631,43 @@ pub async fn ping(pool: &Db) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Merchant API-key management +// --------------------------------------------------------------------------- + +/// Hash a raw API key with SHA-256, returning the hex digest. +/// This is the only representation stored in the database. +fn hash_api_key(raw: &str) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(raw.as_bytes())) +} + +/// Create a merchant row. Returns the merchant `id` — the raw key must be +/// shown to the user by the caller and is not recoverable afterward. +pub async fn create_merchant(pool: &Db, id: &str, raw_key: &str) -> Result<()> { + let hash = hash_api_key(raw_key); + sqlx::query( + "INSERT INTO merchants (id, api_key_hash) VALUES (?, ?)", + ) + .bind(id) + .bind(hash) + .execute(pool) + .await?; + Ok(()) +} + +/// Look up a merchant by their raw API key. Returns `None` if the key does +/// not match any registered merchant. +pub async fn find_merchant_by_key(pool: &Db, raw_key: &str) -> Result> { + let hash = hash_api_key(raw_key); + let id: Option = + sqlx::query_scalar("SELECT id FROM merchants WHERE api_key_hash = ?") + .bind(hash) + .fetch_optional(pool) + .await?; + Ok(id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/api_tests.rs b/tests/api_tests.rs index ca79dd7..8c53510 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -28,6 +28,8 @@ fn make_config() -> Config { // High enough that these tests never trip the limiter; dedicated // rate-limit coverage lives in tests/rate_limit_tests.rs. rate_limit_requests_per_sec: 1000, + db_pool_max_connections: 10, + db_busy_timeout_ms: 5000, cors_allowed_origins: vec![], listener_mode: ListenerMode::Poll, } @@ -62,6 +64,16 @@ async fn test_server() -> TestServer { test_server_with_pool().await.0 } +/// Provision a merchant via POST /merchants and return the API key. +async fn provision_merchant(server: &TestServer) -> String { + let res = server.post("/merchants").await; + res.assert_status(StatusCode::CREATED); + res.json::()["api_key"] + .as_str() + .unwrap() + .to_string() +} + #[tokio::test] async fn test_health() { let res = test_server().await.get("/health").await; @@ -77,10 +89,41 @@ async fn test_ready_ok_with_live_db() { } #[tokio::test] -async fn test_create_payment() { +async fn test_unauthenticated_create_returns_401() { + let res = test_server() + .await + .post("/payments") + .json(&json!({ "amount": "10", "asset": "XLM" })) + .await; + res.assert_status(StatusCode::UNAUTHORIZED); + assert_eq!(res.json::()["code"], "unauthorized"); +} + +#[tokio::test] +async fn test_unauthenticated_list_returns_401() { + let res = test_server().await.get("/payments").await; + res.assert_status(StatusCode::UNAUTHORIZED); + assert_eq!(res.json::()["code"], "unauthorized"); +} + +#[tokio::test] +async fn test_invalid_api_key_returns_401() { let res = test_server() .await .post("/payments") + .add_header("Authorization", "Bearer not-a-real-key") + .json(&json!({ "amount": "10", "asset": "XLM" })) + .await; + res.assert_status(StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_create_payment() { + 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": "10", "asset": "XLM" })) .await; res.assert_status(StatusCode::CREATED); @@ -91,14 +134,13 @@ async fn test_create_payment() { } /// Timestamps must be strict RFC 3339 UTC with an explicit Z suffix. -/// Parsing with `time::OffsetDateTime::parse` using the Rfc3339 format -/// ensures "2026-04-29 15:00:00" (space, no Z) would fail, while -/// "2026-04-29T15:00:00Z" succeeds. #[tokio::test] async fn test_timestamps_are_rfc3339_utc() { - let res = test_server() - .await + 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" })) .await; res.assert_status(StatusCode::CREATED); @@ -120,10 +162,13 @@ async fn test_timestamps_are_rfc3339_utc() { #[tokio::test] async fn test_idempotency_key_returns_same_payment() { let server = test_server().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); // First request mints a new payment (201 Created). let res1 = server .post("/payments") + .add_header("Authorization", auth.clone()) .add_header("Idempotency-Key", "retry-abc-123") .json(&json!({ "amount": "10", "asset": "XLM" })) .await; @@ -133,6 +178,7 @@ async fn test_idempotency_key_returns_same_payment() { // Identical retry with the same key returns the original payment (200 OK). let res2 = server .post("/payments") + .add_header("Authorization", auth.clone()) .add_header("Idempotency-Key", "retry-abc-123") .json(&json!({ "amount": "10", "asset": "XLM" })) .await; @@ -141,17 +187,24 @@ async fn test_idempotency_key_returns_same_payment() { assert_eq!(id1, id2, "same idempotency key must yield the same payment"); - // Exactly one payment exists. - let list: Value = server.get("/payments").await.json(); + // Exactly one payment visible to this merchant. + let list: Value = server + .get("/payments") + .add_header("Authorization", auth) + .await + .json(); assert_eq!(list["total"], 1); } #[tokio::test] async fn test_different_or_missing_idempotency_key_creates_new_payment() { let server = test_server().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); let id_a = server .post("/payments") + .add_header("Authorization", auth.clone()) .add_header("Idempotency-Key", "key-a") .json(&json!({ "amount": "1", "asset": "XLM" })) .await @@ -160,18 +213,18 @@ async fn test_different_or_missing_idempotency_key_creates_new_payment() { .unwrap() .to_string(); - // A different key creates a new payment. let res_b = server .post("/payments") + .add_header("Authorization", auth.clone()) .add_header("Idempotency-Key", "key-b") .json(&json!({ "amount": "1", "asset": "XLM" })) .await; res_b.assert_status(StatusCode::CREATED); let id_b = res_b.json::()["id"].as_str().unwrap().to_string(); - // No key at all also creates a new payment. let res_c = server .post("/payments") + .add_header("Authorization", auth.clone()) .json(&json!({ "amount": "1", "asset": "XLM" })) .await; res_c.assert_status(StatusCode::CREATED); @@ -181,19 +234,26 @@ async fn test_different_or_missing_idempotency_key_creates_new_payment() { assert_ne!(id_a, id_c); assert_ne!(id_b, id_c); - let list: Value = server.get("/payments").await.json(); + let list: Value = server + .get("/payments") + .add_header("Authorization", auth) + .await + .json(); assert_eq!(list["total"], 3); } #[tokio::test] async fn test_idempotency_key_scoped_per_merchant() { let server = test_server().await; + let key1 = provision_merchant(&server).await; + let key2 = provision_merchant(&server).await; - // Same key, different merchants → two distinct payments. + // Same idempotency key, different merchants → two distinct payments. let id_m1 = server .post("/payments") + .add_header("Authorization", format!("Bearer {key1}")) .add_header("Idempotency-Key", "shared-key") - .json(&json!({ "amount": "1", "asset": "XLM", "merchant_id": "m1" })) + .json(&json!({ "amount": "1", "asset": "XLM" })) .await .json::()["id"] .as_str() @@ -202,8 +262,9 @@ async fn test_idempotency_key_scoped_per_merchant() { let id_m2 = server .post("/payments") + .add_header("Authorization", format!("Bearer {key2}")) .add_header("Idempotency-Key", "shared-key") - .json(&json!({ "amount": "1", "asset": "XLM", "merchant_id": "m2" })) + .json(&json!({ "amount": "1", "asset": "XLM" })) .await .json::()["id"] .as_str() @@ -215,21 +276,63 @@ async fn test_idempotency_key_scoped_per_merchant() { "same key under different merchants must not collide" ); - // Re-using m1's key under m1 returns m1's original payment. + // Re-using key1's idempotency key under merchant1 returns merchant1's original payment. let res_retry = server .post("/payments") + .add_header("Authorization", format!("Bearer {key1}")) .add_header("Idempotency-Key", "shared-key") - .json(&json!({ "amount": "1", "asset": "XLM", "merchant_id": "m1" })) + .json(&json!({ "amount": "1", "asset": "XLM" })) .await; res_retry.assert_status_ok(); assert_eq!(res_retry.json::()["id"].as_str().unwrap(), id_m1); } #[tokio::test] -async fn test_create_invalid_asset() { - let res = test_server() +async fn test_merchant_list_scoped_to_own_payments() { + let server = test_server().await; + let key1 = provision_merchant(&server).await; + let key2 = provision_merchant(&server).await; + + // Merchant 1 creates 2 payments. + for _ in 0..2 { + server + .post("/payments") + .add_header("Authorization", format!("Bearer {key1}")) + .json(&json!({ "amount": "1", "asset": "XLM" })) + .await + .assert_status(StatusCode::CREATED); + } + // Merchant 2 creates 1 payment. + server + .post("/payments") + .add_header("Authorization", format!("Bearer {key2}")) + .json(&json!({ "amount": "2", "asset": "XLM" })) + .await + .assert_status(StatusCode::CREATED); + + // Each merchant only sees their own payments. + let list1: Value = server + .get("/payments") + .add_header("Authorization", format!("Bearer {key1}")) .await + .json(); + assert_eq!(list1["total"], 2, "merchant1 should see 2 payments"); + + let list2: Value = server + .get("/payments") + .add_header("Authorization", format!("Bearer {key2}")) + .await + .json(); + assert_eq!(list2["total"], 1, "merchant2 should see 1 payment"); +} + +#[tokio::test] +async fn test_create_invalid_asset() { + 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": "10", "asset": "BTC" })) .await; res.assert_status(StatusCode::BAD_REQUEST); @@ -239,9 +342,11 @@ async fn test_create_invalid_asset() { #[tokio::test] async fn test_create_invalid_amount() { - let res = test_server() - .await + 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" })) .await; res.assert_status(StatusCode::BAD_REQUEST); @@ -250,8 +355,10 @@ async fn test_create_invalid_amount() { #[tokio::test] async fn test_get_by_id() { let server = test_server().await; + let key = provision_merchant(&server).await; let id = server .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) .json(&json!({ "amount": "5", "asset": "USDC" })) .await .json::()["id"] @@ -282,9 +389,11 @@ async fn test_get_not_found() { #[tokio::test] async fn test_reject_too_many_decimals() { - let res = test_server() - .await + 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.00000001", "asset": "XLM" })) .await; res.assert_status(StatusCode::BAD_REQUEST); @@ -292,9 +401,11 @@ async fn test_reject_too_many_decimals() { #[tokio::test] async fn test_asset_is_case_insensitive() { - let res = test_server() - .await + 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": "usdc" })) .await; res.assert_status(StatusCode::CREATED); @@ -303,9 +414,11 @@ async fn test_asset_is_case_insensitive() { #[tokio::test] async fn test_reject_bad_webhook_url() { - let res = test_server() - .await + 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" })) .await; res.assert_status(StatusCode::BAD_REQUEST); @@ -314,14 +427,20 @@ async fn test_reject_bad_webhook_url() { #[tokio::test] async fn test_list_payments() { let server = test_server().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); for amt in ["1", "2", "3"] { server .post("/payments") + .add_header("Authorization", auth.clone()) .json(&json!({ "amount": amt, "asset": "XLM" })) .await; } - let res = server.get("/payments").await; + let res = server + .get("/payments") + .add_header("Authorization", auth) + .await; res.assert_status_ok(); let body: Value = res.json(); assert_eq!(body["total"], 3); @@ -331,38 +450,57 @@ async fn test_list_payments() { #[tokio::test] async fn test_list_filter_by_status() { let server = test_server().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); server .post("/payments") + .add_header("Authorization", auth.clone()) .json(&json!({ "amount": "1", "asset": "XLM" })) .await; - // All created payments start pending, so completed should be empty. - let res = server.get("/payments?status=completed").await; + let res = server + .get("/payments?status=completed") + .add_header("Authorization", auth.clone()) + .await; res.assert_status_ok(); assert_eq!(res.json::()["total"], 0); - let res = server.get("/payments?status=pending").await; + let res = server + .get("/payments?status=pending") + .add_header("Authorization", auth) + .await; assert_eq!(res.json::()["total"], 1); } #[tokio::test] async fn test_list_invalid_status() { - let res = test_server().await.get("/payments?status=bogus").await; + let server = test_server().await; + let key = provision_merchant(&server).await; + let res = server + .get("/payments?status=bogus") + .add_header("Authorization", format!("Bearer {key}")) + .await; res.assert_status(StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_list_cursor_pagination() { let server = test_server().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); for amt in ["1", "2", "3", "4", "5"] { server .post("/payments") + .add_header("Authorization", auth.clone()) .json(&json!({ "amount": amt, "asset": "XLM" })) .await; } // Page 1 via offset path — also returns next_cursor for migration. - let res = server.get("/payments?limit=2").await; + let res = server + .get("/payments?limit=2") + .add_header("Authorization", auth.clone()) + .await; res.assert_status_ok(); let body: Value = res.json(); assert_eq!(body["payments"].as_array().unwrap().len(), 2); @@ -373,6 +511,7 @@ async fn test_list_cursor_pagination() { // Page 2 via keyset cursor. let res2 = server .get(&format!("/payments?cursor={cursor}&limit=2")) + .add_header("Authorization", auth.clone()) .await; res2.assert_status_ok(); let body2: Value = res2.json(); @@ -384,6 +523,7 @@ async fn test_list_cursor_pagination() { // Page 3 — last page, fewer items than limit. let res3 = server .get(&format!("/payments?cursor={cursor2}&limit=2")) + .add_header("Authorization", auth.clone()) .await; res3.assert_status_ok(); let body3: Value = res3.json(); @@ -405,9 +545,11 @@ async fn test_list_cursor_pagination() { #[tokio::test] async fn test_list_cursor_invalid() { - let res = test_server() - .await + let server = test_server().await; + let key = provision_merchant(&server).await; + let res = server .get("/payments?cursor=notvalidhex!!") + .add_header("Authorization", format!("Bearer {key}")) .await; res.assert_status(StatusCode::BAD_REQUEST); } @@ -437,8 +579,10 @@ async fn test_list_webhooks_not_found() { #[tokio::test] async fn test_list_webhooks_empty() { let server = test_server().await; + let key = provision_merchant(&server).await; let id = server .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) .json(&json!({ "amount": "5", "asset": "XLM" })) .await .json::()["id"] @@ -465,8 +609,10 @@ async fn test_redeliver_webhook_not_found() { #[tokio::test] async fn test_redeliver_delivery_not_found() { let server = test_server().await; + let key = provision_merchant(&server).await; let id = server .post("/payments") + .add_header("Authorization", format!("Bearer {key}")) .json(&json!({ "amount": "5", "asset": "XLM" })) .await .json::()["id"] @@ -483,10 +629,13 @@ async fn test_redeliver_delivery_not_found() { #[tokio::test] async fn test_webhook_delivery_isolation() { let (server, pool) = test_server_with_pool().await; + let key = provision_merchant(&server).await; + let auth = format!("Bearer {key}"); // Create two payments let id1 = server .post("/payments") + .add_header("Authorization", auth.clone()) .json(&json!({ "amount": "5", "asset": "XLM" })) .await .json::()["id"] @@ -496,6 +645,7 @@ async fn test_webhook_delivery_isolation() { let id2 = server .post("/payments") + .add_header("Authorization", auth) .json(&json!({ "amount": "10", "asset": "USDC" })) .await .json::()["id"]