open-standards/openpay/crates/op-3ds2/src/message.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
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]>
2026-05-24 04:00:04 -04:00

811 lines
30 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 3-D Secure 2.x message catalogue.
//!
//! Implements the AReq / ARes / CReq / CRes / RReq / RRes / Erro
//! payloads that travel between the 3DS Requestor, 3DS Server,
//! Directory Server (DS) and Access Control Server (ACS).
//!
//! Field names follow the EMVCo 3-D Secure Protocol and Core
//! Functions Specification, sections 6 (Message Detail) and 7
//! (Data Element Detail). Fields are camelCase in the JSON wire
//! format and we use `#[serde(rename_all = "camelCase")]` plus
//! per-field `rename` overrides for the handful of names that don't
//! map cleanly (e.g. `acctID`, `acctNumber`, `acsURL`).
//!
//! ## Validation
//!
//! [`AReq::validate`] runs the per-version rule lookup from
//! [`crate::version::field_rule`] and additionally enforces the
//! device-channel conditionals: `browserInfo` is required for browser
//! flows, `sdkAppID`/`sdkEphemPubKey`/`sdkTransID` are required for
//! app flows, and 3RI flows must populate `threeRIInd`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::risk::{AccountInfo, BrowserInfo, MerchantRiskIndicator};
use crate::version::{FieldRule, ProtocolVersion, field_rule};
/// All 3DS 2.x messages, tagged by `messageType`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "messageType")]
pub enum ThreeDsMessage {
/// Authentication Request (3DS Server → DS → ACS).
AReq(Box<AReq>),
/// Authentication Response (ACS → DS → 3DS Server).
ARes(Box<ARes>),
/// Challenge Request (3DS Server → ACS, via cardholder browser/app).
CReq(Box<CReq>),
/// Challenge Response (ACS → 3DS Server, via cardholder browser/app).
CRes(Box<CRes>),
/// Results Request (ACS → DS → 3DS Server, terminal-state report).
RReq(Box<RReq>),
/// Results Response (3DS Server → DS → ACS, acknowledgement).
RRes(Box<RRes>),
/// Erro — protocol-level error message.
#[serde(rename = "Erro")]
ErrorMessage(Box<ErrorMessage>),
}
/// `deviceChannel` discriminator from the EMVCo spec.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceChannel {
/// `"01"` — app-based 3DS, payload comes from an SDK on a mobile
/// device.
#[serde(rename = "01")]
App,
/// `"02"` — browser-based 3DS, payload comes from a web checkout.
#[serde(rename = "02")]
Browser,
/// `"03"` — 3RI (3DS Requestor Initiated), no cardholder present.
#[serde(rename = "03")]
ThreeRi,
}
/// `messageCategory` discriminator.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageCategory {
/// `"01"` — Payment Authentication (PA). The cardholder is paying.
#[serde(rename = "01")]
Payment,
/// `"02"` — Non-Payment Authentication (NPA). Account-binding,
/// add-card, no money movement.
#[serde(rename = "02")]
NonPayment,
}
/// **AReq** — Authentication Request.
///
/// 130+ fields per the EMVCo catalogue. We carry the load-bearing
/// subset by name and stash the rest in [`AReq::extensions`] so the
/// codec is forward-compatible with scheme-specific addenda.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AReq {
/// Spec version this message targets. Wire field: `messageVersion`.
pub message_version: String,
/// Server-side transaction id. UUIDv4. Required.
#[serde(rename = "threeDSServerTransID")]
pub three_ds_server_trans_id: String,
/// 3DS Server reference number (assigned by EMVCo on registration).
#[serde(rename = "threeDSServerRefNumber")]
pub three_ds_server_ref_number: String,
/// Operator URL the DS should use for the RReq callback.
#[serde(rename = "threeDSServerURL")]
pub three_ds_server_url: String,
/// Requestor (merchant) id assigned by the DS.
#[serde(rename = "threeDSRequestorID")]
pub three_ds_requestor_id: String,
/// Requestor name assigned by the DS.
#[serde(rename = "threeDSRequestorName")]
pub three_ds_requestor_name: String,
/// `"01"` (no preference), `"02"` (no challenge requested),
/// `"03"` (challenge requested),
/// `"04"` (challenge mandated for regulatory reasons),
/// `"05"` (no challenge requested, TRA exemption),
/// `"06"` (no challenge requested, data-share only),
/// `"07"` (no challenge requested, SCP exemption),
/// `"08"` (no challenge requested, low-value exemption),
/// `"09"` (no challenge requested, trusted-beneficiary).
#[serde(rename = "threeDSRequestorChallengeInd")]
pub three_ds_requestor_challenge_ind: String,
/// PA / NPA category. Wire: `messageCategory`.
pub message_category: MessageCategory,
/// Where the user is. Wire: `deviceChannel`.
pub device_channel: DeviceChannel,
/// PAN of the card being authenticated. Wire: `acctNumber`.
pub acct_number: String,
/// Cardholder type: `"01"` Not a credit account, `"02"` Credit,
/// `"03"` Debit.
#[serde(skip_serializing_if = "Option::is_none")]
pub acct_type: Option<String>,
/// Merchant name as it appears on the cardholder's statement.
pub merchant_name: String,
/// MCC (ISO 18245 four-digit numeric).
pub mcc: String,
/// Acquirer BIN (assigned by the scheme).
#[serde(rename = "acquirerBIN")]
pub acquirer_bin: String,
/// Acquirer merchant id assigned by the acquirer.
pub acquirer_merchant_id: String,
/// Three-digit numeric ISO 3166-1.
pub merchant_country_code: String,
/// Three-digit numeric ISO 4217.
pub purchase_currency: String,
/// Whole-number amount in the currency's minor units, as a string.
/// The 3DS spec carries it as a numeric string (max 48 digits).
pub purchase_amount: String,
/// Number of decimal places the currency has. ISO 4217 exponent.
pub purchase_exponent: u8,
/// Date of the purchase in `YYYYMMDDHHMMSS` UTC format.
pub purchase_date: String,
/// Browser data envelope. Required when `deviceChannel == Browser`.
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_info: Option<BrowserInfo>,
/// SDK app id. Required when `deviceChannel == App`.
#[serde(rename = "sdkAppID", skip_serializing_if = "Option::is_none")]
pub sdk_app_id: Option<String>,
/// SDK ephemeral public key (JWK). Required when
/// `deviceChannel == App`.
#[serde(skip_serializing_if = "Option::is_none")]
pub sdk_ephem_pub_key: Option<String>,
/// SDK reference number assigned to the SDK build by EMVCo.
#[serde(skip_serializing_if = "Option::is_none")]
pub sdk_reference_number: Option<String>,
/// SDK transaction id. UUIDv4. Required when
/// `deviceChannel == App`.
#[serde(rename = "sdkTransID", skip_serializing_if = "Option::is_none")]
pub sdk_trans_id: Option<String>,
/// Max time (minutes) the SDK is willing to wait for the ARes.
/// Required when `deviceChannel == App`. Minimum value 5.
#[serde(skip_serializing_if = "Option::is_none")]
pub sdk_max_timeout: Option<u32>,
/// 3RI indicator. Required when `deviceChannel == ThreeRi`.
/// `"01"` Recurring, `"02"` Installment, `"03"` Add card,
/// `"04"` Maintain card, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub three_ri_ind: Option<String>,
/// Authentication method used by the requestor to authenticate the
/// cardholder before this transaction. `"01"` No 3DSR auth,
/// `"02"` Login w/ requestor's own credentials, `"03"` Federated id,
/// `"04"` Issuer credentials, `"05"` Third-party authentication,
/// `"06"` FIDO authenticator.
#[serde(skip_serializing_if = "Option::is_none")]
pub three_ds_req_auth_method: Option<String>,
/// Decoupled authentication indicator. `"Y"` requested,
/// `"N"` not requested. 2.2.0+.
#[serde(skip_serializing_if = "Option::is_none")]
pub decoupled_auth_ind: Option<String>,
/// Max minutes the requestor is willing to wait for the decoupled
/// outcome. 2.2.0+.
#[serde(skip_serializing_if = "Option::is_none")]
pub decoupled_auth_max_time: Option<u32>,
/// Whitelisting / trusted-beneficiary status. `"01"`-`"04"`. 2.2.0+.
#[serde(skip_serializing_if = "Option::is_none")]
pub white_list_status: Option<String>,
/// SPC (Secure Payment Confirmation) incompatibility indicator.
/// 2.3.0 only.
#[serde(skip_serializing_if = "Option::is_none")]
pub spc_incomp: Option<String>,
/// Delegated authentication evidence. 2.3.0 only.
#[serde(skip_serializing_if = "Option::is_none")]
pub delegated_auth_data: Option<String>,
/// Merchant risk indicator envelope.
#[serde(skip_serializing_if = "Option::is_none")]
pub merchant_risk_indicator: Option<MerchantRiskIndicator>,
/// Account information envelope.
#[serde(rename = "acctInfo", skip_serializing_if = "Option::is_none")]
pub acct_info: Option<AccountInfo>,
/// Forward-compatible bag for scheme extensions (Visa CIT/MIT
/// indicators, Mastercard Identity Check addenda, etc.).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<MessageExtension>,
}
impl AReq {
/// Validate the message against the per-version field rules and
/// device-channel conditionals. Returns the first violation.
pub fn validate(&self, version: ProtocolVersion) -> Result<()> {
// Version-wide constants. (Spec required everywhere.)
if self.three_ds_server_trans_id.is_empty() {
return Err(Error::MissingField {
version,
field: "threeDSServerTransID",
});
}
if self.message_version.is_empty() {
return Err(Error::MissingField {
version,
field: "messageVersion",
});
}
if self.acct_number.is_empty() {
return Err(Error::MissingField {
version,
field: "acctNumber",
});
}
// Device-channel conditionals.
match self.device_channel {
DeviceChannel::Browser => {
if self.browser_info.is_none() {
return Err(Error::MissingField {
version,
field: "browserInfo",
});
}
}
DeviceChannel::App => {
if self.sdk_app_id.is_none() {
return Err(Error::MissingField {
version,
field: "sdkAppID",
});
}
if self.sdk_ephem_pub_key.is_none() {
return Err(Error::MissingField {
version,
field: "sdkEphemPubKey",
});
}
if self.sdk_trans_id.is_none() {
return Err(Error::MissingField {
version,
field: "sdkTransID",
});
}
if self.sdk_max_timeout.is_none_or(|t| t < 5) {
return Err(Error::MissingField {
version,
field: "sdkMaxTimeout",
});
}
}
DeviceChannel::ThreeRi => {
if self.three_ri_ind.is_none() {
return Err(Error::MissingField {
version,
field: "threeRIInd",
});
}
// 3RI is forbidden in 2.1.0.
if matches!(field_rule("threeRIInd", version), FieldRule::Forbidden) {
return Err(Error::ForbiddenField {
version,
field: "threeRIInd",
});
}
}
}
// Cross-version field-rule checks for the optional flags that
// change classification across versions.
if self.spc_incomp.is_some()
&& matches!(field_rule("spcIncomp", version), FieldRule::Forbidden)
{
return Err(Error::ForbiddenField {
version,
field: "spcIncomp",
});
}
if self.decoupled_auth_ind.is_some()
&& matches!(field_rule("decoupledAuthInd", version), FieldRule::Forbidden)
{
return Err(Error::ForbiddenField {
version,
field: "decoupledAuthInd",
});
}
if self.delegated_auth_data.is_some()
&& matches!(field_rule("delegatedAuthData", version), FieldRule::Forbidden)
{
return Err(Error::ForbiddenField {
version,
field: "delegatedAuthData",
});
}
if self.white_list_status.is_some()
&& matches!(field_rule("whiteListStatus", version), FieldRule::Forbidden)
{
return Err(Error::ForbiddenField {
version,
field: "whiteListStatus",
});
}
Ok(())
}
}
/// **ARes** — Authentication Response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ARes {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo of the AReq id.
pub three_ds_server_trans_id: String,
#[serde(rename = "dsTransID")]
/// DS-assigned id, UUIDv4.
pub ds_trans_id: String,
#[serde(rename = "acsTransID")]
/// ACS-assigned id, UUIDv4.
pub acs_trans_id: String,
/// One of [`crate::auth_response::TransactionStatus`].
pub trans_status: String,
/// ACS-issued reference number (assigned by EMVCo on registration).
#[serde(rename = "acsReferenceNumber")]
pub acs_reference_number: String,
/// ACS operator id.
#[serde(rename = "acsOperatorID")]
pub acs_operator_id: String,
/// URL the cardholder browser/app should POST CReq to when a
/// challenge is required.
#[serde(rename = "acsURL", skip_serializing_if = "Option::is_none")]
pub acs_url: Option<String>,
/// HTML-formatted ACS challenge mandated string. Optional unless
/// `transStatus == "C"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub acs_challenge_mandated: Option<String>,
/// Cardholder authentication value — the cryptogram the acquirer
/// will forward in the auth request. Base64. Optional unless
/// `transStatus == "Y"` or `"A"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_value: Option<String>,
/// Electronic Commerce Indicator. Scheme-specific.
#[serde(skip_serializing_if = "Option::is_none")]
pub eci: Option<String>,
/// ACS-assigned signature for the ARes (used by the SDK to verify
/// the ARes hasn't been tampered with). JWS compact.
#[serde(skip_serializing_if = "Option::is_none")]
pub acs_signed_content: Option<String>,
/// Reason transStatus == "U" (system unavailable) or "R" (rejected).
#[serde(skip_serializing_if = "Option::is_none")]
pub trans_status_reason: Option<String>,
/// Forward-compat bag.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<MessageExtension>,
}
/// **CReq** — Challenge Request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CReq {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo from AReq/ARes.
pub three_ds_server_trans_id: String,
#[serde(rename = "acsTransID")]
/// Echo from ARes.
pub acs_trans_id: String,
/// Challenge window size. `"01"` 250×400, `"02"` 390×400,
/// `"03"` 500×600, `"04"` 600×400, `"05"` full screen.
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_window_size: Option<String>,
/// SDK-side counter, increments per challenge transmission.
#[serde(rename = "sdkCounterStoA", skip_serializing_if = "Option::is_none")]
pub sdk_counter_s_to_a: Option<String>,
/// Cardholder response payload (filled when re-submitting after
/// the ACS rendered the challenge UI).
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_data_entry: Option<String>,
/// Cancel indicator: `"01"` cardholder selected cancel.
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_cancel: Option<String>,
/// Resend challenge indicator: `"Y"` resend.
#[serde(skip_serializing_if = "Option::is_none")]
pub resend_challenge: Option<String>,
}
/// **CRes** — Challenge Response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CRes {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo.
pub three_ds_server_trans_id: String,
#[serde(rename = "acsTransID")]
/// Echo.
pub acs_trans_id: String,
/// ACS-side counter, increments per challenge transmission.
#[serde(rename = "acsCounterAtoS", skip_serializing_if = "Option::is_none")]
pub acs_counter_a_to_s: Option<String>,
/// HTML body, base64-encoded for HTML challenge mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub acs_html: Option<String>,
/// "Y"/"N". When "Y" the challenge has completed and the
/// cardholder may continue.
#[serde(skip_serializing_if = "Option::is_none")]
pub challenge_completion_ind: Option<String>,
/// Transaction status — same enum as on ARes. Populated on the
/// final CRes only.
#[serde(skip_serializing_if = "Option::is_none")]
pub trans_status: Option<String>,
/// Reason transStatus == "U" / "R" / "N".
#[serde(skip_serializing_if = "Option::is_none")]
pub trans_status_reason: Option<String>,
/// OOB-mode app URL the SDK should open.
#[serde(rename = "oobAppURL", skip_serializing_if = "Option::is_none")]
pub oob_app_url: Option<String>,
/// OOB-mode app label (button text).
#[serde(rename = "oobAppLabel", skip_serializing_if = "Option::is_none")]
pub oob_app_label: Option<String>,
/// Decoupled-mode polling URL.
#[serde(rename = "acsDecConURL", skip_serializing_if = "Option::is_none")]
pub acs_decoupled_url: Option<String>,
}
/// **RReq** — Results Request (ACS → DS → 3DS Server, terminal report).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RReq {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo.
pub three_ds_server_trans_id: String,
#[serde(rename = "dsTransID")]
/// Echo.
pub ds_trans_id: String,
#[serde(rename = "acsTransID")]
/// Echo.
pub acs_trans_id: String,
/// Final transaction status.
pub trans_status: String,
/// Cryptogram (base64).
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_value: Option<String>,
/// Electronic Commerce Indicator.
#[serde(skip_serializing_if = "Option::is_none")]
pub eci: Option<String>,
/// Reason for non-success status.
#[serde(skip_serializing_if = "Option::is_none")]
pub trans_status_reason: Option<String>,
/// Authentication type: `"01"` static, `"02"` dynamic, `"03"` OOB,
/// `"04"` decoupled.
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_type: Option<String>,
/// Interaction counter — number of challenge cycles.
#[serde(skip_serializing_if = "Option::is_none")]
pub interaction_counter: Option<String>,
/// Date of completion in `YYYYMMDDHHMMSS` UTC.
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_method: Option<String>,
}
/// **RRes** — Results Response (acknowledgement of the RReq).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RRes {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo.
pub three_ds_server_trans_id: String,
/// "01" Results Acknowledged.
pub results_status: String,
}
/// **Erro** — Protocol-level error message.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorMessage {
/// Wire field: `messageVersion`.
pub message_version: String,
#[serde(rename = "threeDSServerTransID")]
/// Echo (when available).
pub three_ds_server_trans_id: String,
/// EMVCo five-digit error code.
pub error_code: String,
/// Free-form description.
pub error_description: String,
/// Component that raised: `"A"` ACS, `"C"` 3DS SDK,
/// `"D"` DS, `"S"` 3DS Server.
pub error_component: String,
/// Optional field name that triggered the error.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_detail: Option<String>,
/// Spec version this message indicates was expected.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message_type: Option<String>,
/// Timestamp the error was raised at.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_raised_at: Option<DateTime<Utc>>,
}
/// Generic scheme-specific extension envelope, attached as
/// `extensions[]` to AReq / ARes per EMVCo.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageExtension {
/// Vendor-prefixed extension name.
pub name: String,
/// EMVCo-assigned extension id.
pub id: String,
/// Whether the receiver must understand this extension or fail.
pub criticality_indicator: bool,
/// Free-form data block (typically a nested JSON object stringified).
pub data: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::risk::BrowserInfo;
fn sample_browser_areq() -> 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: "4111111111111111".into(),
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![],
}
}
#[test]
fn browser_areq_validates_at_2_2() {
let a = sample_browser_areq();
assert!(a.validate(ProtocolVersion::V2_2).is_ok());
assert!(a.validate(ProtocolVersion::V2_3).is_ok());
assert!(a.validate(ProtocolVersion::V2_1).is_ok());
}
#[test]
fn browser_areq_missing_browser_info_fails() {
let mut a = sample_browser_areq();
a.browser_info = None;
let err = a.validate(ProtocolVersion::V2_2).unwrap_err();
assert!(matches!(
err,
Error::MissingField {
field: "browserInfo",
..
}
));
}
#[test]
fn app_areq_requires_sdk_fields() {
let mut a = sample_browser_areq();
a.device_channel = DeviceChannel::App;
a.browser_info = None;
let err = a.validate(ProtocolVersion::V2_2).unwrap_err();
assert!(matches!(err, Error::MissingField { field: "sdkAppID", .. }));
}
#[test]
fn threeri_forbidden_in_2_1() {
let mut a = sample_browser_areq();
a.device_channel = DeviceChannel::ThreeRi;
a.three_ri_ind = Some("01".into());
a.browser_info = None;
let err = a.validate(ProtocolVersion::V2_1).unwrap_err();
assert!(matches!(
err,
Error::ForbiddenField {
field: "threeRIInd",
..
}
));
}
#[test]
fn spc_forbidden_below_2_3() {
let mut a = sample_browser_areq();
a.spc_incomp = Some("Y".into());
let err = a.validate(ProtocolVersion::V2_2).unwrap_err();
assert!(matches!(
err,
Error::ForbiddenField {
field: "spcIncomp",
..
}
));
// OK under 2.3.0.
assert!(a.validate(ProtocolVersion::V2_3).is_ok());
}
#[test]
fn decoupled_forbidden_in_2_1() {
let mut a = sample_browser_areq();
a.decoupled_auth_ind = Some("Y".into());
let err = a.validate(ProtocolVersion::V2_1).unwrap_err();
assert!(matches!(
err,
Error::ForbiddenField {
field: "decoupledAuthInd",
..
}
));
}
#[test]
fn round_trip_browser_areq_json() {
let a = sample_browser_areq();
let json = serde_json::to_string(&a).unwrap();
let parsed: AReq = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.acct_number, a.acct_number);
assert_eq!(parsed.message_version, "2.2.0");
assert!(json.contains("\"acctNumber\""));
assert!(json.contains("\"threeDSServerTransID\""));
assert!(json.contains("\"deviceChannel\":\"02\""));
}
#[test]
fn round_trip_ares() {
let r = ARes {
message_version: "2.2.0".into(),
three_ds_server_trans_id: "trans-123".into(),
ds_trans_id: "ds-456".into(),
acs_trans_id: "acs-789".into(),
trans_status: "Y".into(),
acs_reference_number: "3DS_LOA_ACS_VISA_020100_00001".into(),
acs_operator_id: "visa-acs".into(),
acs_url: None,
acs_challenge_mandated: None,
authentication_value: Some("BASE64CAVV==".into()),
eci: Some("05".into()),
acs_signed_content: None,
trans_status_reason: None,
extensions: vec![],
};
let json = serde_json::to_string(&r).unwrap();
let back: ARes = serde_json::from_str(&json).unwrap();
assert_eq!(back.eci.as_deref(), Some("05"));
assert!(json.contains("\"transStatus\":\"Y\""));
}
#[test]
fn round_trip_creq_cres() {
let creq = CReq {
message_version: "2.2.0".into(),
three_ds_server_trans_id: "t".into(),
acs_trans_id: "a".into(),
challenge_window_size: Some("05".into()),
sdk_counter_s_to_a: Some("001".into()),
challenge_data_entry: None,
challenge_cancel: None,
resend_challenge: None,
};
let cres = 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("001".into()),
acs_html: Some("PGh0bWw+".into()),
challenge_completion_ind: Some("N".into()),
trans_status: None,
trans_status_reason: None,
oob_app_url: None,
oob_app_label: None,
acs_decoupled_url: None,
};
assert!(serde_json::to_string(&creq).unwrap().contains("\"challengeWindowSize\""));
assert!(serde_json::to_string(&cres).unwrap().contains("\"acsHTML\"") || serde_json::to_string(&cres).unwrap().contains("\"acsHtml\""));
}
#[test]
fn round_trip_rreq_rres() {
let rreq = RReq {
message_version: "2.2.0".into(),
three_ds_server_trans_id: "t".into(),
ds_trans_id: "d".into(),
acs_trans_id: "a".into(),
trans_status: "Y".into(),
authentication_value: Some("CAVV==".into()),
eci: Some("05".into()),
trans_status_reason: None,
authentication_type: Some("02".into()),
interaction_counter: Some("01".into()),
authentication_method: None,
};
let rres = RRes {
message_version: "2.2.0".into(),
three_ds_server_trans_id: "t".into(),
results_status: "01".into(),
};
let _ = serde_json::to_string(&rreq).unwrap();
let _ = serde_json::to_string(&rres).unwrap();
}
#[test]
fn error_message_round_trip() {
let e = ErrorMessage {
message_version: "2.2.0".into(),
three_ds_server_trans_id: "t".into(),
error_code: "203".into(),
error_description: "Required field missing".into(),
error_component: "S".into(),
error_detail: Some("acctNumber".into()),
error_message_type: Some("AReq".into()),
error_raised_at: None,
};
let s = serde_json::to_string(&e).unwrap();
let back: ErrorMessage = serde_json::from_str(&s).unwrap();
assert_eq!(back.error_code, "203");
}
#[test]
fn tagged_enum_round_trip() {
let msg = ThreeDsMessage::AReq(Box::new(sample_browser_areq()));
let s = serde_json::to_string(&msg).unwrap();
assert!(s.contains("\"messageType\":\"AReq\""));
let back: ThreeDsMessage = serde_json::from_str(&s).unwrap();
match back {
ThreeDsMessage::AReq(_) => {}
_ => panic!("expected AReq variant"),
}
}
}