open-standards/openpay/crates/op-webhook/tests/integration.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

475 lines
16 KiB
Rust

//! Integration tests for `op-webhook`.
//!
//! These tests exercise the **operator-level pattern**: a ledger
//! event triggers a webhook fanout to merchant-configured endpoints,
//! with the resulting signature verifiable by the receiver using
//! standard `verify_signature` logic.
//!
//! `op-webhook` has NO compile-time dependency on `op-ledger` — the
//! integration is at the operator's layer. We build a payload that
//! looks like a ledger event but the dispatcher treats it as opaque.
use std::sync::Arc;
use op_webhook::retry::ExponentialBackoffPolicy;
use op_webhook::signing::{parse_signature_header, verify_signature};
use op_webhook::transport::MockResponse;
use op_webhook::{
DispatchOutcome, Endpoint, EndpointStatus, HttpResponse, HttpTransport, InMemoryWebhookStore,
MockTransport, RetryPolicy, SIGNATURE_HEADER, SignedPayload, WebhookDispatcher, WebhookEvent,
WebhookStore,
};
/// Synthetic ledger-event payload. JSON for verisimilitude.
fn ledger_txn_posted_payload(tx_id: &str, amount: i64) -> Vec<u8> {
format!(
r#"{{"event":"ledger.transaction.posted","tx_id":"{tx_id}","amount_minor":{amount},"currency":"USD"}}"#,
)
.into_bytes()
}
fn setup_dispatcher(
threshold: u32,
fixed_jitter: u64,
clock: u64,
) -> (
Arc<InMemoryWebhookStore>,
Arc<MockTransport>,
WebhookDispatcher,
) {
let store: Arc<InMemoryWebhookStore> = Arc::new(InMemoryWebhookStore::new());
let transport: Arc<MockTransport> = Arc::new(MockTransport::new());
let policy: Arc<dyn RetryPolicy> = Arc::new(ExponentialBackoffPolicy::deterministic(
2,
60,
72 * 3600,
threshold,
fixed_jitter,
));
let dispatcher = WebhookDispatcher::new(
store.clone() as Arc<dyn WebhookStore>,
transport.clone() as Arc<dyn HttpTransport>,
policy,
)
.with_clock(move || clock);
(store, transport, dispatcher)
}
// ============================================================
// Test 1: Happy-path — event delivered, signature verifies
// ============================================================
#[test]
fn fanout_happy_path_signature_verifies_on_receiver_side() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1_700_000_000);
let secret = b"whsec_merchant_acme_001".to_vec();
let endpoint = Endpoint::new(
"https://merchant.example.com/hooks/payments",
secret.clone(),
vec!["ledger.transaction.posted".into()],
)
.unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
let payload = ledger_txn_posted_payload("tx-1", 525);
let event = WebhookEvent::new("ledger.transaction.posted", payload.clone(), 1_700_000_000);
let outcomes = dispatcher.dispatch(event).unwrap();
assert_eq!(outcomes.len(), 1);
match &outcomes[0] {
DispatchOutcome::Delivered {
endpoint_id: eid,
status,
} => {
assert_eq!(*eid, endpoint_id);
assert_eq!(*status, 200);
}
o => panic!("expected Delivered, got {o:?}"),
}
// Simulate the merchant's signature verification.
let captured = transport.take_captured();
assert_eq!(captured.len(), 1);
let req = &captured[0];
let header = req
.headers
.iter()
.find(|(k, _)| k == SIGNATURE_HEADER)
.unwrap()
.1
.clone();
// Receiver-side verification with `now` matching the dispatcher
// clock (the dispatcher uses its own `now` as the signed
// timestamp).
verify_signature(&secret, &req.body, &header, 1_700_000_000, 300).unwrap();
}
// ============================================================
// Test 2: Tampering — modified body fails verification
// ============================================================
#[test]
fn modified_body_fails_signature_verification() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1_700_000_000);
let secret = b"whsec_test".to_vec();
let endpoint =
Endpoint::new("https://x.example/h", secret.clone(), vec!["*".to_string()]).unwrap();
store.put_endpoint(endpoint).unwrap();
let _ = dispatcher
.dispatch(WebhookEvent::new("any", b"ok".to_vec(), 1_700_000_000))
.unwrap();
let captured = transport.take_captured();
let req = &captured[0];
let header = req
.headers
.iter()
.find(|(k, _)| k == SIGNATURE_HEADER)
.unwrap()
.1
.clone();
// Attempt verification with a tampered body.
let tampered_body = b"tampered";
let r = verify_signature(&secret, tampered_body, &header, 1_700_000_000, 300);
assert!(matches!(r, Err(op_webhook::Error::SignatureMismatch)));
}
// ============================================================
// Test 3: Replay — old timestamp outside tolerance is rejected
// ============================================================
#[test]
fn old_timestamp_outside_tolerance_is_rejected() {
let secret = b"whsec_test";
let body = b"event-body";
// Signed at t=1000.
let signed = SignedPayload::new(1000, body);
let sig = op_webhook::signing::compute_signature(secret, &signed).unwrap();
let header = format!("t=1000,v1={sig}");
// Receiver's now is 10000 → delta 9000 > tolerance 300.
let r = verify_signature(secret, body, &header, 10000, 300);
assert!(matches!(
r,
Err(op_webhook::Error::TimestampOutOfTolerance { .. })
));
}
// ============================================================
// Test 4: Auto-disable after N consecutive failures
// ============================================================
#[test]
fn endpoint_auto_disables_after_threshold_failures() {
let (store, transport, dispatcher) = setup_dispatcher(3, 0, 1000);
let endpoint = Endpoint::new(
"https://flaky.example/h",
b"whsec".to_vec(),
vec!["*".to_string()],
)
.unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
// 3 consecutive failures — threshold is 3.
transport.push_5xx(503);
transport.push_5xx(503);
transport.push_5xx(503);
for i in 0..3 {
let _ = dispatcher
.dispatch(WebhookEvent::new(
"any",
format!("evt-{i}").into_bytes(),
1000,
))
.unwrap();
}
let ep = store.get_endpoint(endpoint_id).unwrap();
assert_eq!(ep.status, EndpointStatus::AutoDisabled);
// Future dispatches are skipped.
let outcomes = dispatcher
.dispatch(WebhookEvent::new("any", b"new".to_vec(), 1000))
.unwrap();
// The new event matches but the endpoint is auto-disabled →
// list_active_endpoints_for filters it out → no outcomes.
assert!(outcomes.is_empty());
}
// ============================================================
// Test 5: Fanout — multiple endpoints, mixed outcomes
// ============================================================
#[test]
fn fanout_multiple_endpoints_each_get_signed_independently() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1_700_000_000);
let secret_a = b"whsec_a".to_vec();
let secret_b = b"whsec_b".to_vec();
let ep_a = Endpoint::new(
"https://a.example/h",
secret_a.clone(),
vec!["*".to_string()],
)
.unwrap();
let ep_b = Endpoint::new(
"https://b.example/h",
secret_b.clone(),
vec!["*".to_string()],
)
.unwrap();
store.put_endpoint(ep_a).unwrap();
store.put_endpoint(ep_b).unwrap();
let _ = dispatcher
.dispatch(WebhookEvent::new("any", b"payload".to_vec(), 1_700_000_000))
.unwrap();
let captured = transport.take_captured();
assert_eq!(captured.len(), 2);
// Each captured request carries its own signature; each
// signature should verify only against ITS endpoint's secret.
for req in &captured {
let header = req
.headers
.iter()
.find(|(k, _)| k == SIGNATURE_HEADER)
.unwrap()
.1
.clone();
if req.url.contains("a.example") {
verify_signature(&secret_a, &req.body, &header, 1_700_000_000, 300).unwrap();
// The OTHER secret must fail.
let r = verify_signature(&secret_b, &req.body, &header, 1_700_000_000, 300);
assert!(matches!(r, Err(op_webhook::Error::SignatureMismatch)));
} else if req.url.contains("b.example") {
verify_signature(&secret_b, &req.body, &header, 1_700_000_000, 300).unwrap();
} else {
panic!("unexpected url: {}", req.url);
}
}
}
// ============================================================
// Test 6: Retry happens after scheduled delay, succeeds on second try
// ============================================================
#[test]
fn retry_succeeds_via_process_due_retries() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1000);
let secret = b"whsec".to_vec();
let endpoint = Endpoint::new("https://x.example/h", secret, vec!["*".to_string()]).unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
// First attempt 5xx → retry scheduled.
transport.push_5xx(503);
let event = WebhookEvent::new("any", b"payload".to_vec(), 1000);
let event_id = event.id;
let outcomes = dispatcher.dispatch(event).unwrap();
assert!(matches!(outcomes[0], DispatchOutcome::Retrying { .. }));
// Advance the clock. Build a NEW dispatcher with a later clock
// but the same store + transport. (The original dispatcher's
// clock is fixed at 1000.)
let later_policy: Arc<dyn RetryPolicy> = Arc::new(ExponentialBackoffPolicy::deterministic(
2,
60,
72 * 3600,
10,
0,
));
let later_dispatcher = WebhookDispatcher::new(
store.clone() as Arc<dyn WebhookStore>,
transport.clone() as Arc<dyn HttpTransport>,
later_policy,
)
.with_clock(|| 10000);
transport.push_ok();
let outcomes = later_dispatcher.process_due_retries().unwrap();
assert_eq!(outcomes.len(), 1);
assert!(matches!(outcomes[0], DispatchOutcome::Delivered { .. }));
// Audit trail: two attempts for this (event, endpoint) pair.
let attempts = store.list_attempts(event_id, endpoint_id).unwrap();
assert!(
attempts.len() >= 2,
"expected at least 2 attempts, got {}",
attempts.len()
);
}
// ============================================================
// Test 7: Replay after re-enabling an auto-disabled endpoint
// ============================================================
#[test]
fn replay_works_after_operator_reenables_endpoint() {
let (store, transport, dispatcher) = setup_dispatcher(2, 0, 1000);
let endpoint = Endpoint::new(
"https://flaky.example/h",
b"whsec".to_vec(),
vec!["*".to_string()],
)
.unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
// Force the endpoint to auto-disable.
transport.push_5xx(500);
transport.push_5xx(500);
let event = WebhookEvent::new("a", b"x".to_vec(), 1000);
let event_id = event.id;
let _ = dispatcher.dispatch(event).unwrap();
let _ = dispatcher
.dispatch(WebhookEvent::new("b", b"y".to_vec(), 1000))
.unwrap();
let ep = store.get_endpoint(endpoint_id).unwrap();
assert_eq!(ep.status, EndpointStatus::AutoDisabled);
// Operator fixed the receiver and re-enables.
store
.set_endpoint_status(endpoint_id, EndpointStatus::Active)
.unwrap();
// Replay the first event. Queue a 200 for it.
transport.push_ok();
let outcome = dispatcher.replay(event_id, endpoint_id).unwrap();
assert!(matches!(outcome, DispatchOutcome::Delivered { .. }));
}
// ============================================================
// Test 8: 4xx fails fast — no retry, dead letter on first attempt
// ============================================================
#[test]
fn four_oh_four_dead_letters_without_retry() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1000);
let endpoint = Endpoint::new(
"https://x.example/h",
b"whsec".to_vec(),
vec!["*".to_string()],
)
.unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
transport.push_response(MockResponse::Response(HttpResponse {
status: 404,
body: b"Not found".to_vec(),
}));
let event = WebhookEvent::new("any", b"x".to_vec(), 1000);
let event_id = event.id;
let outcomes = dispatcher.dispatch(event).unwrap();
match &outcomes[0] {
DispatchOutcome::DeadLetter {
endpoint_id: eid,
reason,
} => {
assert_eq!(*eid, endpoint_id);
assert_eq!(reason, "http_404");
}
o => panic!("expected DeadLetter, got {o:?}"),
}
// Audit: exactly one attempt with status Failed.
let attempts = store.list_attempts(event_id, endpoint_id).unwrap();
assert_eq!(attempts.len(), 1);
assert_eq!(attempts[0].status, op_webhook::DeliveryStatus::Failed);
}
// ============================================================
// Test 9: Stripe-style header is round-trippable
// ============================================================
#[test]
fn signature_header_is_parseable_by_stripe_style_consumers() {
// Generate one of our headers and verify it parses into the
// structure a Stripe-compatible consumer expects.
let secret = b"whsec_test";
let body = b"{\"k\":\"v\"}";
let ts = 1_700_000_000u64;
let sig =
op_webhook::signing::compute_signature(secret, &SignedPayload::new(ts, body)).unwrap();
let header = op_webhook::signing::build_signature_header(ts, &sig);
// Parse like a Stripe consumer would.
let (parsed_ts, sigs) = parse_signature_header(&header).unwrap();
assert_eq!(parsed_ts, ts);
assert_eq!(sigs.len(), 1);
assert_eq!(sigs[0].len(), 64); // SHA-256 hex
}
// ============================================================
// Test 10: Retries audit trail accumulates correctly
// ============================================================
#[test]
fn audit_trail_records_every_attempt() {
let (store, transport, dispatcher) = setup_dispatcher(10, 0, 1000);
let endpoint = Endpoint::new(
"https://x.example/h",
b"whsec".to_vec(),
vec!["*".to_string()],
)
.unwrap();
let endpoint_id = endpoint.id;
store.put_endpoint(endpoint).unwrap();
let event = WebhookEvent::new("any", b"x".to_vec(), 1000);
let event_id = event.id;
// First attempt 5xx.
transport.push_5xx(502);
let _ = dispatcher.dispatch(event).unwrap();
// Process retry later — also fails.
let later_policy: Arc<dyn RetryPolicy> = Arc::new(ExponentialBackoffPolicy::deterministic(
2,
60,
72 * 3600,
10,
0,
));
let later = WebhookDispatcher::new(
store.clone() as Arc<dyn WebhookStore>,
transport.clone() as Arc<dyn HttpTransport>,
later_policy,
)
.with_clock(|| 10000);
transport.push_5xx(503);
let _ = later.process_due_retries().unwrap();
// Process again — succeeds.
let later2_policy: Arc<dyn RetryPolicy> = Arc::new(ExponentialBackoffPolicy::deterministic(
2,
60,
72 * 3600,
10,
0,
));
let later2 = WebhookDispatcher::new(
store.clone() as Arc<dyn WebhookStore>,
transport.clone() as Arc<dyn HttpTransport>,
later2_policy,
)
.with_clock(|| 20000);
transport.push_ok();
let _ = later2.process_due_retries().unwrap();
let attempts = store.list_attempts(event_id, endpoint_id).unwrap();
// We expect 3 attempts at this point: original (Failed/superseded),
// retry-1 (Failed/superseded), retry-2 (Succeeded). The first
// two attempts will have been marked Failed by
// process_due_retries (the supersession trick).
assert!(
attempts.len() >= 3,
"expected at least 3 attempts, got {}",
attempts.len()
);
// The last one should be Succeeded.
let last = attempts.last().unwrap();
assert_eq!(last.status, op_webhook::DeliveryStatus::Succeeded);
}
// (end of file)