OpenPay (3 crates, ~13k LOC, 175 tests) op-connect sub-merchant onboarding + KYB + split-payments + payouts + 1099-K op-3ds2 3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions) op-tax US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters Smart Byte (3 crates, ~7k LOC, 57 tests) smart-byte-libp2p libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp) smart-byte-crdt LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop smart-byte-keri-witness KERI key-rotation cycle + witness layer + watchers + recovery + delegation EOC (3 crates, ~8k LOC, 108 tests) eoc-tools function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox eoc-multimodal vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router eoc-spec-decode vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests. Co-Authored-By: Claude Opus 4.7 <[email protected]>
280 lines
9.7 KiB
Rust
280 lines
9.7 KiB
Rust
#![allow(
|
|
clippy::inconsistent_digit_grouping,
|
|
clippy::field_reassign_with_default,
|
|
clippy::missing_errors_doc,
|
|
clippy::unreadable_literal,
|
|
clippy::doc_markdown
|
|
)]
|
|
|
|
//! End-to-end integration tests for op-3ds2.
|
|
//!
|
|
//! These exercise the cross-module wiring that the unit tests inside
|
|
//! each module cannot reach: DS routing → version negotiation →
|
|
//! AReq → mocked DS → ARes (with correct ECI / CAVV per scheme),
|
|
//! decoupled poll loop, fingerprint round-trip.
|
|
|
|
use chrono::Utc;
|
|
use op_3ds2::{
|
|
AReq, AcsConfig, AcsServer, BrowserFingerprint, ChallengeMode, ChallengeSession,
|
|
DecoupledPollResult, DecoupledSession, DeviceChannel, DirectoryServer, EligibleExemption,
|
|
Error, ExemptionContext, FraudRateBracket, MessageCategory, ProtocolVersion,
|
|
TransactionStatus, evaluate,
|
|
amex_ds::AmexDs,
|
|
directory_server::{DsRoute, route_for_pan},
|
|
exemption,
|
|
fingerprint::fingerprint_collector_script,
|
|
mc_ds::MastercardDs,
|
|
risk::BrowserInfo,
|
|
visa_ds::VisaDs,
|
|
};
|
|
use op_core::{Currency, Money};
|
|
|
|
fn sample_browser_areq(pan: &str) -> AReq {
|
|
AReq {
|
|
message_version: "2.2.0".into(),
|
|
three_ds_server_trans_id: "11111111-1111-1111-1111-111111111111".into(),
|
|
three_ds_server_ref_number: "3DS_LOA_SER_OPNP_020100_00001".into(),
|
|
three_ds_server_url: "https://3ds.openpay.example/callback".into(),
|
|
three_ds_requestor_id: "openpay_req_id".into(),
|
|
three_ds_requestor_name: "OpenPay Merchant".into(),
|
|
three_ds_requestor_challenge_ind: "01".into(),
|
|
message_category: MessageCategory::Payment,
|
|
device_channel: DeviceChannel::Browser,
|
|
acct_number: pan.to_owned(),
|
|
acct_type: Some("02".into()),
|
|
merchant_name: "OpenPay Merchant".into(),
|
|
mcc: "5411".into(),
|
|
acquirer_bin: "400000".into(),
|
|
acquirer_merchant_id: "MID-OPENPAY-001".into(),
|
|
merchant_country_code: "840".into(),
|
|
purchase_currency: "840".into(),
|
|
purchase_amount: "1234".into(),
|
|
purchase_exponent: 2,
|
|
purchase_date: "20260524093000".into(),
|
|
browser_info: Some(BrowserInfo::sample()),
|
|
sdk_app_id: None,
|
|
sdk_ephem_pub_key: None,
|
|
sdk_reference_number: None,
|
|
sdk_trans_id: None,
|
|
sdk_max_timeout: None,
|
|
three_ri_ind: None,
|
|
three_ds_req_auth_method: None,
|
|
decoupled_auth_ind: None,
|
|
decoupled_auth_max_time: None,
|
|
white_list_status: None,
|
|
spc_incomp: None,
|
|
delegated_auth_data: None,
|
|
merchant_risk_indicator: None,
|
|
acct_info: None,
|
|
extensions: vec![],
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn visa_areq_round_trip_via_stub_ds() {
|
|
let pan = "4111111111111111";
|
|
assert_eq!(route_for_pan(pan).unwrap(), DsRoute::Visa);
|
|
let ds = VisaDs::default();
|
|
let vc = ds.version_check(pan).await.unwrap();
|
|
let chosen = vc.negotiate(&[ProtocolVersion::V2_3, ProtocolVersion::V2_2]).unwrap();
|
|
assert_eq!(chosen, ProtocolVersion::V2_3);
|
|
|
|
let areq = sample_browser_areq(pan);
|
|
areq.validate(chosen).unwrap();
|
|
let ares = ds.auth_request(&areq).await.unwrap();
|
|
assert_eq!(ares.trans_status, "Y");
|
|
assert_eq!(ares.eci.as_deref(), Some("05"));
|
|
assert!(ares.authentication_value.is_some(), "Visa ARes must carry CAVV");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mastercard_areq_round_trip_with_02_eci() {
|
|
let pan = "5500000000000004";
|
|
assert_eq!(route_for_pan(pan).unwrap(), DsRoute::Mastercard);
|
|
let ds = MastercardDs::default();
|
|
let vc = ds.version_check(pan).await.unwrap();
|
|
let chosen = vc
|
|
.negotiate(&[ProtocolVersion::V2_3, ProtocolVersion::V2_2, ProtocolVersion::V2_1])
|
|
.unwrap();
|
|
assert_eq!(chosen, ProtocolVersion::V2_3);
|
|
|
|
let areq = sample_browser_areq(pan);
|
|
let ares = ds.auth_request(&areq).await.unwrap();
|
|
assert_eq!(ares.eci.as_deref(), Some("02"), "Mastercard success ECI is 02");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn amex_areq_round_trip_with_05_eci() {
|
|
let pan = "378282246310005";
|
|
assert_eq!(route_for_pan(pan).unwrap(), DsRoute::Amex);
|
|
let ds = AmexDs::default();
|
|
let ares = ds.auth_request(&sample_browser_areq(pan)).await.unwrap();
|
|
assert_eq!(ares.eci.as_deref(), Some("05"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn version_negotiation_falls_back_when_2_3_unsupported() {
|
|
// Mastercard stub advertises 2.1.0 / 2.2.0 / 2.3.0; trim it.
|
|
let mut ds = MastercardDs::default();
|
|
ds.acs_reference_number = "3DS_LOA_ACS_MCDS_020200_00001".into();
|
|
let vc = ds.version_check("5500000000000004").await.unwrap();
|
|
let chosen = vc.negotiate(&[ProtocolVersion::V2_2, ProtocolVersion::V2_1]).unwrap();
|
|
assert_eq!(chosen, ProtocolVersion::V2_2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decoupled_poll_settles_after_three_pending() {
|
|
let s = DecoupledSession {
|
|
polling_url: "https://acs.example/poll".into(),
|
|
three_ds_server_trans_id: "tid".into(),
|
|
decoupled_auth_max_time: 10,
|
|
poll_interval: std::time::Duration::from_millis(1),
|
|
max_polls: 10,
|
|
};
|
|
let counter = std::sync::Arc::new(std::sync::Mutex::new(0_u32));
|
|
let result = s
|
|
.run(|_| {
|
|
let counter = std::sync::Arc::clone(&counter);
|
|
async move {
|
|
let mut c = counter.lock().unwrap();
|
|
*c += 1;
|
|
if *c < 3 {
|
|
Ok(DecoupledPollResult::Pending)
|
|
} else {
|
|
Ok(DecoupledPollResult::Approved {
|
|
authentication_value: "CAVV==".into(),
|
|
eci: "05".into(),
|
|
})
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(result, DecoupledPollResult::Approved { .. }));
|
|
assert_eq!(*counter.lock().unwrap(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn fingerprint_collector_emits_script_then_parses_back() {
|
|
let script = fingerprint_collector_script("https://collector.example", "tid-x");
|
|
assert!(script.contains("collector.example"));
|
|
let body = "ua=Mozilla&accept=text%2Fhtml&lang=en-US&langs=en-US%2Cen\
|
|
&sw=1920&sh=1080&cd=24&tz=-420&java=0";
|
|
let fp = BrowserFingerprint::parse_form(body).unwrap();
|
|
assert_eq!(fp.screen_width, 1920);
|
|
assert_eq!(fp.timezone_offset, -420);
|
|
let bi = fp.into_browser_info(Some("198.51.100.1".into()));
|
|
assert_eq!(bi.user_agent, "Mozilla");
|
|
assert_eq!(bi.ip.as_deref(), Some("198.51.100.1"));
|
|
}
|
|
|
|
#[test]
|
|
fn exemption_low_value_25_eur() {
|
|
let intent = exemption::PaymentIntent {
|
|
amount: Money::from_minor(25_00, Currency::EUR),
|
|
pan: "4111111111111111".into(),
|
|
recurring: false,
|
|
merchant_initiated: false,
|
|
mandate_ref: None,
|
|
subscription_id: None,
|
|
};
|
|
let ctx = ExemptionContext {
|
|
fraud_rate_bracket: FraudRateBracket::AboveTraThreshold,
|
|
..Default::default()
|
|
};
|
|
let ex = evaluate(&intent, &ctx);
|
|
assert!(
|
|
ex.iter()
|
|
.any(|e| matches!(e, EligibleExemption::LowValueTransaction { .. }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exemption_100_eur_qualifies_for_tra_only_when_bracket_allows() {
|
|
let intent = exemption::PaymentIntent {
|
|
amount: Money::from_minor(100_00, Currency::EUR),
|
|
pan: "4111111111111111".into(),
|
|
recurring: false,
|
|
merchant_initiated: false,
|
|
mandate_ref: None,
|
|
subscription_id: None,
|
|
};
|
|
// AboveTraThreshold → not eligible.
|
|
let ctx_bad = ExemptionContext {
|
|
fraud_rate_bracket: FraudRateBracket::AboveTraThreshold,
|
|
tra_score: Some(0.05),
|
|
..Default::default()
|
|
};
|
|
let ex = evaluate(&intent, &ctx_bad);
|
|
assert!(
|
|
!ex.iter()
|
|
.any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. }))
|
|
);
|
|
// UpTo13Bp (cap 100 EUR) → eligible (boundary inclusive).
|
|
let ctx_ok = ExemptionContext {
|
|
fraud_rate_bracket: FraudRateBracket::UpTo13Bp,
|
|
tra_score: Some(0.05),
|
|
..Default::default()
|
|
};
|
|
let ex = evaluate(&intent, &ctx_ok);
|
|
assert!(
|
|
ex.iter()
|
|
.any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn acs_emits_ares_with_decision_letter() {
|
|
let server = AcsServer::new(AcsConfig {
|
|
acs_operator_id: "openpay-acs".into(),
|
|
acs_reference_number: "3DS_LOA_ACS_OPNP_020300_00001".into(),
|
|
acs_url: "https://acs.openpay.example/challenge".into(),
|
|
acs_decoupled_url: Some("https://acs.openpay.example/poll".into()),
|
|
});
|
|
let areq = sample_browser_areq("4111111111111111");
|
|
let ares = server.build_ares(
|
|
&areq,
|
|
TransactionStatus::ChallengeRequired,
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(ares.trans_status, "C");
|
|
assert!(ares.acs_url.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn challenge_session_builds_initial_creq() {
|
|
let s = ChallengeSession::new("t-1".into(), "a-1".into(), ChallengeMode::Html);
|
|
let c = s.initial_creq("2.2.0", "05");
|
|
assert_eq!(c.three_ds_server_trans_id, "t-1");
|
|
assert_eq!(c.acs_trans_id, "a-1");
|
|
}
|
|
|
|
#[test]
|
|
fn challenge_session_settles_authenticated_cres() {
|
|
let s = ChallengeSession::new("t".into(), "a".into(), ChallengeMode::Html);
|
|
let cres = op_3ds2::CRes {
|
|
message_version: "2.2.0".into(),
|
|
three_ds_server_trans_id: "t".into(),
|
|
acs_trans_id: "a".into(),
|
|
acs_counter_a_to_s: Some("002".into()),
|
|
acs_html: None,
|
|
challenge_completion_ind: Some("Y".into()),
|
|
trans_status: Some("Y".into()),
|
|
trans_status_reason: None,
|
|
oob_app_url: None,
|
|
oob_app_label: None,
|
|
acs_decoupled_url: None,
|
|
};
|
|
let r = s.settle(cres, Utc::now()).unwrap();
|
|
assert_eq!(r.trans_status, TransactionStatus::Authenticated);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_pan_no_route() {
|
|
assert!(matches!(route_for_pan(""), Err(Error::InvalidPan)));
|
|
assert!(matches!(
|
|
route_for_pan("9999999999999999"),
|
|
Err(Error::NoDsRoute { .. })
|
|
));
|
|
}
|