open-standards/openpay/crates/op-server/tests/api.rs
Transaction Science af2729cbb7
All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 4m57s
openpay: record each payment, keep the payer's mandate, bound refunds by it
op-server ran payment intents through the orchestrator and kept nothing a
caller could read back, and a refund named any transaction id unchecked.

- op-graph: GraphPaymentStore, one `payment` vertex per idempotency key
  (UUIDv5, so a replay lands on the same record): amount, outcome, rail,
  driver, metadata and the optional `mandate`. A later outcome for the same
  key (a replay, a resume after a challenge) updates only the outcome.
- POST /v1/intents takes an optional `mandate` (the payer's signed
  authorization, e.g. an agent's approval or standing grant, AP2-style),
  stored as given; the response carries `payment_id`.
- GET /v1/payments/{id}: the record, mandate included, and `refunded_minor`
  computed from the refunds filed against it (not declined or failed).
- POST /v1/refunds whose original_tx_id names a recorded payment must
  refund an approved payment, in its currency, and no more than is left; a
  replay of a filed refund is left to the store's idempotency. Other ids
  (ledger transactions recorded elsewhere) are unchecked, as before.

Tests: the store round-trips and keeps the mandate across outcomes; over
HTTP a payment is recorded with its mandate, a replayed refund is not a
second one, an over-refund and a wrong currency are refused, a declined
payment has nothing to refund. op-server and op-graph suites pass; the
openpay workspace checks --locked; clippy and rustfmt clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:48:16 -05:00

719 lines
23 KiB
Rust

//! HTTP integration tests via `tower::ServiceExt::oneshot` — no
//! sockets, no listener, just direct router invocation. Tests the
//! full handler stack: JSON parsing → store → JSON out.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use op_server::{AppState, router};
use op_webhook::{HttpTransport, MockTransport};
use serde_json::{Value, json};
use tower::util::ServiceExt;
async fn body_to_json(body: Body) -> Value {
let bytes = body.collect().await.expect("collect body").to_bytes();
serde_json::from_slice(&bytes).expect("body is json")
}
fn json_request(method: &str, path: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
fn empty_request(method: &str, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn health_returns_ok() {
let app = router(AppState::new_in_memory());
let res = app.oneshot(empty_request("GET", "/health")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn readiness_returns_counts() {
let app = router(AppState::new_in_memory());
let res = app
.oneshot(empty_request("GET", "/readiness"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "ready");
assert_eq!(body["refunds"], 0);
}
#[tokio::test]
async fn create_refund_round_trip() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 7500,
"currency": "USD",
"reason": "customer_request",
"external_id": "ext-1",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["amount_minor"], 7500);
assert_eq!(body["currency"], "USD");
assert_eq!(body["status"], "requested");
let refund_id = body["id"].as_str().unwrap();
let res = app
.oneshot(empty_request("GET", &format!("/v1/refunds/{refund_id}")))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["external_id"], "ext-1");
}
#[tokio::test]
async fn refund_unknown_returns_404() {
let app = router(AppState::new_in_memory());
let missing = uuid::Uuid::now_v7();
let res = app
.oneshot(empty_request("GET", &format!("/v1/refunds/{missing}")))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["code"], "not_found");
}
#[tokio::test]
async fn refund_idempotency_replay_returns_same() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "USD",
"reason": "duplicate_charge",
"external_id": "ext-idem",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res1 = app
.clone()
.oneshot(json_request("POST", "/v1/refunds", req.clone()))
.await
.unwrap();
assert_eq!(res1.status(), StatusCode::OK);
let body1 = body_to_json(res1.into_body()).await;
let res2 = app
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res2.status(), StatusCode::OK);
let body2 = body_to_json(res2.into_body()).await;
assert_eq!(body1["id"], body2["id"]);
}
#[tokio::test]
async fn create_dispute_round_trip() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 9999,
"currency": "USD",
"reason": "fraudulent",
"network_reason_code": "10.4",
"external_id": "disp-1",
"opened_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.oneshot(json_request("POST", "/v1/disputes", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "chargeback");
assert_eq!(body["network_reason_code"], "10.4");
}
#[tokio::test]
async fn open_batch_and_close() {
let app = router(AppState::new_in_memory());
let req = json!({
"currency": "USD",
"rail": "ach_nacha",
"external_id": "batch-1",
"opened_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/settlement/batches", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
let bid = body["id"].as_str().unwrap();
assert_eq!(body["status"], "open");
// Add an entry.
let tx_id = uuid::Uuid::now_v7();
let entry = json!({
"tx_id": tx_id.to_string(),
"amount_minor": 12345,
});
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/settlement/batches/{bid}/entries"),
entry,
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["entry_count"], 1);
assert_eq!(body["gross_minor"], 12345);
// Close with 1% reserve.
let close = json!({
"flat_rate_bps": 100,
"max_total_bps": 10000,
"dispute_adjustment_bps": 0,
"closed_at_unix_secs": 1_700_001_000_u64,
});
let res = app
.oneshot(json_request(
"POST",
&format!("/v1/settlement/batches/{bid}/close"),
close,
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "closed");
assert_eq!(body["reserve_minor"], 123); // 1% of 12345
}
#[tokio::test]
async fn audit_report_empty_window() {
let app = router(AppState::new_in_memory());
let res = app
.oneshot(empty_request(
"GET",
"/v1/audit/report?start_tx=0&end_tx=100&generated_at_unix_secs=9999",
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert!(body["entries"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn subscription_create_pause_resume_cancel() {
let app = router(AppState::new_in_memory());
let req = json!({
"customer_ref": "cust-1",
"plan_name": "Pro Monthly",
"amount_minor": 4_900,
"currency": "USD",
"interval": "month",
"interval_count": 1,
"trial_days": 14,
"method": { "type": "vault", "token": "tok_v7_test" },
"external_id": "sub-1",
"now_unix_secs": 1_700_000_000_u64,
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/subscriptions", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "trialing");
assert_eq!(body["plan_name"], "Pro Monthly");
let sid = body["id"].as_str().unwrap().to_owned();
// Pause.
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/pause"),
json!({ "now_unix_secs": 1_700_000_100_u64 }),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "paused");
// Resume.
let res = app
.clone()
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/resume"),
json!({}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "active");
// List by customer.
let res = app
.clone()
.oneshot(empty_request(
"GET",
"/v1/subscriptions?customer_ref=cust-1",
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body.as_array().unwrap().len(), 1);
// Cancel immediately.
let res = app
.oneshot(json_request(
"POST",
&format!("/v1/subscriptions/{sid}/cancel"),
json!({ "at_period_end": false, "now_unix_secs": 1_700_000_200_u64 }),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "canceled");
}
#[tokio::test]
async fn fx_quote_returns_configured_rate() {
use op_core::Currency;
use op_fx::StaticQuoteProvider;
let provider: Arc<dyn op_fx::QuoteProvider> =
Arc::new(StaticQuoteProvider::new().with_rate(Currency::EUR, Currency::USD, 1_082_500));
let state = AppState::new_in_memory().with_fx_provider(provider);
let app = router(state);
let res = app
.oneshot(empty_request("GET", "/v1/fx/quote?from=EUR&to=USD"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["from"], "EUR");
assert_eq!(body["to"], "USD");
assert_eq!(body["rate_ppm"], 1_082_500);
assert_eq!(body["source"], "static");
}
#[tokio::test]
async fn fx_convert_applies_rate() {
use op_core::Currency;
use op_fx::StaticQuoteProvider;
let provider: Arc<dyn op_fx::QuoteProvider> =
Arc::new(StaticQuoteProvider::new().with_rate(Currency::EUR, Currency::USD, 1_082_500));
let state = AppState::new_in_memory().with_fx_provider(provider);
let app = router(state);
let res = app
.oneshot(json_request(
"POST",
"/v1/fx/convert",
json!({
"from": "EUR",
"to": "USD",
"amount_minor": 10_000,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["source_amount_minor"], 10_000);
assert_eq!(body["target_amount_minor"], 10_825);
assert_eq!(body["rounding"], "half_even");
}
#[tokio::test]
async fn fx_quote_for_unknown_pair_returns_404() {
let state = AppState::new_in_memory();
let app = router(state);
let res = app
.oneshot(empty_request("GET", "/v1/fx/quote?from=USD&to=JPY"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["code"], "not_found");
}
#[tokio::test]
async fn refund_creation_fires_webhook_to_subscribed_endpoint() {
let mock = Arc::new(MockTransport::new());
mock.push_ok();
let transport: Arc<dyn HttpTransport> = mock.clone();
let state = AppState::new_in_memory().with_webhook_transport(transport);
let app = router(state);
// Register a webhook endpoint subscribed to refund.created.
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/webhooks/endpoints",
json!({
"url": "https://example.test/refund-hook",
"secret": "shared-secret-xyz",
"event_filters": ["refund.created"],
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["status"], "active");
// Create a refund.
let tx_id = uuid::Uuid::now_v7();
let res = app
.oneshot(json_request(
"POST",
"/v1/refunds",
json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 250,
"currency": "USD",
"reason": "customer_request",
"external_id": "ext-webhook",
"requested_at_unix_secs": 1_700_000_000_u64,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// The mock transport should have seen exactly one outbound delivery.
assert_eq!(mock.captured_count(), 1);
let captured = mock.take_captured();
assert_eq!(captured[0].url, "https://example.test/refund-hook");
// Payload is the refund response JSON; verify a field is present.
let payload_str = std::str::from_utf8(&captured[0].body).unwrap();
assert!(payload_str.contains("\"external_id\":\"ext-webhook\""));
assert!(payload_str.contains("\"amount_minor\":250"));
}
#[tokio::test]
async fn refund_creation_skips_disabled_endpoint() {
let mock = Arc::new(MockTransport::new());
let transport: Arc<dyn HttpTransport> = mock.clone();
let state = AppState::new_in_memory().with_webhook_transport(transport);
let app = router(state);
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/webhooks/endpoints",
json!({
"url": "https://example.test/disabled-hook",
"secret": "s",
"event_filters": ["refund.created"],
}),
))
.await
.unwrap();
let body = body_to_json(res.into_body()).await;
let endpoint_id = body["id"].as_str().unwrap().to_owned();
// Disable it.
let _ = app
.clone()
.oneshot(empty_request(
"POST",
&format!("/v1/webhooks/endpoints/{endpoint_id}/disable"),
))
.await
.unwrap();
let tx_id = uuid::Uuid::now_v7();
let _ = app
.oneshot(json_request(
"POST",
"/v1/refunds",
json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "USD",
"reason": "duplicate_charge",
"external_id": "ext-disabled",
"requested_at_unix_secs": 1_700_000_000_u64,
}),
))
.await
.unwrap();
assert_eq!(
mock.captured_count(),
0,
"disabled endpoint should not receive deliveries"
);
}
#[tokio::test]
async fn intent_create_then_resume_after_challenge() {
use op_core::{Currency, Money, PaymentMethod, VaultRef};
use op_driver_sdk::DeterministicCardAcquirer;
use op_orchestrator::{CardAdapter, Orchestrator, PolicyRouter};
use op_rails_card::acquirer::AuthStatus;
// Configure the acquirer so the test idempotency key triggers
// a challenge.
let acquirer = Arc::new(DeterministicCardAcquirer::new().with_key_override(
"intent-3ds-1",
AuthStatus::RequiresCustomerAction,
None,
));
let card = Arc::new(CardAdapter::new("det-card", acquirer));
let mut orch = Orchestrator::new().with_router(Box::new(PolicyRouter::new(
vec!["det-card".to_owned()],
vec![],
)));
orch.register_adapter(card);
let state = AppState::new_in_memory().with_orchestrator(orch);
let app = router(state);
// Step 1: create intent. Expect requires_customer_action.
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/intents",
json!({
"idempotency_key": "intent-3ds-1",
"amount_minor": 1_500,
"currency": "USD",
"method": { "type": "vault", "token": "tok_v7_3ds" },
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["terminal_status"], "requires_customer_action");
let psp_id = body["psp_payment_id"].as_str().unwrap().to_owned();
let _ = Currency::USD;
let _ = Money::from_minor(0, Currency::USD);
let _ = PaymentMethod::Vault(VaultRef::new("x"));
// Step 2: resume after challenge. Expect approved.
let res = app
.oneshot(json_request(
"POST",
"/v1/intents/resume",
json!({
"idempotency_key": "intent-3ds-1",
"amount_minor": 1_500,
"currency": "USD",
"method": { "type": "vault", "token": "tok_v7_3ds" },
"rail": "card",
"driver": "det-card",
"psp_payment_id": psp_id,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["terminal_status"], "approved");
assert_eq!(body["psp_payment_id"], psp_id);
}
#[tokio::test]
async fn bad_currency_returns_400() {
let app = router(AppState::new_in_memory());
let tx_id = uuid::Uuid::now_v7();
let req = json!({
"original_tx_id": tx_id.to_string(),
"amount_minor": 100,
"currency": "usd", // lowercase
"reason": "customer_request",
"requested_at_unix_secs": 1_700_000_000_u64,
});
let res = app
.oneshot(json_request("POST", "/v1/refunds", req))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn a_sandbox_server_approves_an_intent_over_http_and_names_its_rail() {
let state =
op_server::build_state_from_env(|k| (k == "OP_SANDBOX_RAIL").then(|| "1".to_owned()))
.unwrap();
let app = router(state);
let intent = json!({
"idempotency_key": "sandbox-http-1",
"amount_minor": 1_250,
"currency": "USD",
"method": { "type": "vault", "token": "tok_sandbox" },
});
let res = app
.clone()
.oneshot(json_request("POST", "/v1/intents", intent.clone()))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
assert_eq!(body["terminal_status"], "approved");
assert_eq!(body["attempts"][0]["driver"], "sandbox-card");
let psp = body["psp_payment_id"].as_str().unwrap().to_owned();
assert!(psp.starts_with("psp_det_"), "{psp}");
// The same idempotency key and body give the same payment, not a second one.
let again = app
.oneshot(json_request("POST", "/v1/intents", intent))
.await
.unwrap();
assert_eq!(
body_to_json(again.into_body()).await["psp_payment_id"],
psp.as_str()
);
}
#[tokio::test]
async fn a_payment_is_recorded_with_its_mandate_and_refunds_are_bounded_by_it() {
let state =
op_server::build_state_from_env(|k| (k == "OP_SANDBOX_RAIL").then(|| "1".to_owned()))
.unwrap();
let app = router(state.clone());
let mandate = json!({"by": "grant", "descriptor": {"plan_id": "p1"}, "grant": {"body": {"grant_id": "lunch"}}});
let res = app
.clone()
.oneshot(json_request(
"POST",
"/v1/intents",
json!({
"idempotency_key": "pay-record-1",
"amount_minor": 450,
"currency": "USD",
"method": { "type": "vault", "token": "tok_sandbox" },
"metadata": [["payee", "shop:bakery"]],
"mandate": mandate,
}),
))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_to_json(res.into_body()).await;
let id = body["payment_id"].as_str().unwrap().to_owned();
let get = |app: axum::Router, id: String| async move {
let res = app
.oneshot(
Request::builder()
.uri(format!("/v1/payments/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
(res.status(), body_to_json(res.into_body()).await)
};
let (status, p) = get(app.clone(), id.clone()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(p["terminal_status"], "approved");
assert_eq!(p["amount_minor"], 450);
assert_eq!(p["driver"], "sandbox-card");
assert_eq!(p["mandate"], mandate);
assert_eq!(p["refunded_minor"], 0);
let refund = |amount: i64, currency: &str, ext: &str| {
json!({
"original_tx_id": id,
"amount_minor": amount,
"currency": currency,
"reason": "customer_request",
"external_id": ext,
"requested_at_unix_secs": 1_800_000_000u64,
})
};
let post = |app: axum::Router, b: Value| async move {
app.oneshot(json_request("POST", "/v1/refunds", b))
.await
.unwrap()
.status()
};
assert_eq!(
post(app.clone(), refund(300, "USD", "r1")).await,
StatusCode::OK
);
// The same refund again is a replay, not a second refund.
assert_eq!(
post(app.clone(), refund(300, "USD", "r1")).await,
StatusCode::OK
);
// 150 are left: 200 more is refused, and so is another currency.
assert_eq!(
post(app.clone(), refund(200, "USD", "r2")).await,
StatusCode::CONFLICT
);
assert_eq!(
post(app.clone(), refund(100, "EUR", "r3")).await,
StatusCode::BAD_REQUEST
);
assert_eq!(
post(app.clone(), refund(150, "USD", "r4")).await,
StatusCode::OK
);
assert_eq!(get(app.clone(), id.clone()).await.1["refunded_minor"], 450);
// A payment that was not approved has nothing to refund.
let declined = state
.payments
.record(op_graph::PaymentRecord {
id: op_graph::GraphPaymentStore::id_for("pay-declined"),
idempotency_key: "pay-declined".into(),
amount_minor: 100,
currency: "USD".into(),
terminal_status: "declined".into(),
psp_payment_id: None,
rail: None,
driver: None,
metadata: vec![],
mandate: None,
})
.unwrap();
let mut r = refund(50, "USD", "r5");
r["original_tx_id"] = json!(declined.id);
assert_eq!(post(app.clone(), r).await, StatusCode::CONFLICT);
assert_eq!(
get(app, uuid::Uuid::nil().to_string()).await.0,
StatusCode::NOT_FOUND
);
}