open-standards/openpay/crates/op-vault/tests/lifecycle.rs
Transaction Science f4ac4a70c9
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m38s
standards workspaces build + test / aac-rs (pull_request) Successful in 54s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 37s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 37s
standards workspaces build + test / map-rs (pull_request) Successful in 34s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m22s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m13s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m47s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m32s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m34s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m32s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m14s
op-vault: test that a token is independent of the PAN, not free of "4242"
pan_never_appears_in_token_string asserted that the token never contains
"424242" or "4242". The token is TOKEN_PREFIX plus a UUIDv7 in hex, and a
random hex string contains "4242" about once in 8,700 tokens (23 of
200,000 UUIDv7s did), so the test failed by chance: it failed the
openpay leg of standards-build on an unrelated PR (#102). A missing
substring said nothing about whether the token is derived from the card
anyway.

Keep the full-PAN check and test the real property: the same card
tokenized twice gets two different tokens, neither containing the PAN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 23:07:55 -04:00

237 lines
8 KiB
Rust

//! End-to-end lifecycle test for op-vault.
//!
//! Exercises the full PCI scope-reduction flow:
//! 1. Customer enters card data → CardData (validated, Luhn-checked)
//! 2. Vault tokenizes → VaultRef (opaque, contains no PAN)
//! 3. Orchestrator hands VaultRef around (no PCI exposure)
//! 4. Rail driver, at submit time, detokenizes → recovered CardData
//! 5. PSP receives the PAN, returns auth result
//! 6. Vault retains the token for future charges (or single-use)
//!
//! These tests use the in-memory reference vault. Production deploys
//! plug the same Vault trait into Keychain / Keystore / HSM / KMS.
#![cfg(feature = "in-memory")]
use std::sync::Arc;
use std::thread;
use op_vault::{
CardData, Error, InMemoryVault, TokenLifetime, TokenizationPolicy, Vault, VaultRef,
};
const VALID_VISA: &str = "4242424242424242";
const VALID_MC: &str = "5555555555554444";
fn card_visa() -> CardData {
CardData::new(VALID_VISA, 12, 2030).unwrap()
}
#[test]
fn full_lifecycle_tokenize_pass_around_detokenize() {
// Step 1: customer enters card
let card = card_visa();
let last_four = card.last_four().to_owned();
let first_six = card.first_six().to_owned();
// Step 2: vault tokenizes
let vault = Arc::new(InMemoryVault::ephemeral("primary"));
let token = vault
.tokenize(card, TokenizationPolicy::card_on_file())
.unwrap();
// Step 3: hand the token to the "orchestrator" — just a function
// that holds the VaultRef and decides routing without touching PAN.
fn orchestrator_decision(_t: &VaultRef) -> &'static str {
// The orchestrator has zero ability to read the PAN here.
// It can route based only on metadata (rail, amount, geo).
"route_to_card_rail"
}
assert_eq!(orchestrator_decision(&token), "route_to_card_rail");
// Step 4: rail driver detokenizes at submit time
let recovered = vault.detokenize(&token).unwrap();
assert_eq!(recovered.last_four(), last_four);
assert_eq!(recovered.first_six(), first_six);
// Step 5: token is still valid for the next charge (card-on-file)
let recovered_again = vault.detokenize(&token).unwrap();
assert_eq!(recovered_again.last_four(), last_four);
}
#[test]
fn single_use_token_for_3ds_auth() {
// 3DS authentication completes in <2 minutes. Use a single-use
// token with a short TTL to bound the risk window.
let vault = InMemoryVault::ephemeral("3ds");
let policy = TokenizationPolicy::single_use(120);
let token = vault.tokenize(card_visa(), policy).unwrap();
// 3DS handler resolves the token once.
let _card = vault.detokenize(&token).unwrap();
// Replay attempt fails closed.
let err = vault.detokenize(&token).unwrap_err();
assert!(matches!(err, Error::AlreadyConsumed));
}
#[test]
fn vault_shared_across_threads() {
// The orchestrator runs worker threads, each holding an Arc<dyn Vault>.
let vault: Arc<dyn Vault> = Arc::new(InMemoryVault::ephemeral("shared"));
let token = vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap();
// Spawn workers that each detokenize and verify.
let handles: Vec<_> = (0..8)
.map(|_| {
let v = Arc::clone(&vault);
let t = token.clone();
thread::spawn(move || {
let card = v.detokenize(&t).unwrap();
card.last_four().to_owned()
})
})
.collect();
for h in handles {
assert_eq!(h.join().unwrap(), "4242");
}
}
#[test]
fn multiple_cards_remain_isolated() {
let vault = InMemoryVault::ephemeral("multi");
let c_visa = CardData::new(VALID_VISA, 12, 2030).unwrap();
let c_mc = CardData::new(VALID_MC, 6, 2028).unwrap();
let t_visa = vault
.tokenize(c_visa, TokenizationPolicy::default())
.unwrap();
let t_mc = vault.tokenize(c_mc, TokenizationPolicy::default()).unwrap();
let r_visa = vault.detokenize(&t_visa).unwrap();
let r_mc = vault.detokenize(&t_mc).unwrap();
assert_eq!(r_visa.last_four(), "4242");
assert_eq!(r_mc.last_four(), "4444");
assert_eq!(r_visa.first_six(), "424242");
assert_eq!(r_mc.first_six(), "555555");
}
#[test]
fn deleted_token_is_not_recoverable() {
let vault = InMemoryVault::ephemeral("retention");
let token = vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap();
// Customer removes their card.
let removed = vault.delete(&token).unwrap();
assert!(removed);
// Subsequent detokenize fails closed.
let err = vault.detokenize(&token).unwrap_err();
assert!(matches!(err, Error::NotFound));
// Idempotent: deleting again is a no-op.
let removed_again = vault.delete(&token).unwrap();
assert!(!removed_again);
}
#[test]
fn pan_never_appears_in_token_string() {
let vault = InMemoryVault::ephemeral("opacity");
let token = vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap();
let token_str = token.as_str();
// The full PAN must not appear in the token.
assert!(!token_str.contains(VALID_VISA));
// Nor may the token be derived from the card: the same card tokenized
// twice gets two different tokens. (This test used to assert that the
// token never contains "4242" or "424242". The token is a UUIDv7 in hex,
// and a random hex string does contain "4242" now and then: 23 of 200,000
// UUIDv7s did. So it failed by chance, and the absence of a substring
// said nothing about derivability anyway.)
let again = vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap();
assert_ne!(
token_str,
again.as_str(),
"the same card tokenized twice gave the same token"
);
assert!(!again.as_str().contains(VALID_VISA));
}
#[test]
fn debug_format_of_card_data_masks_pan() {
let card = card_visa();
let dbg = format!("{card:?}");
assert!(dbg.contains("424242")); // BIN allowed
assert!(dbg.contains("4242")); // last4 allowed
assert!(!dbg.contains("4242424242424242")); // never full PAN
}
#[test]
fn many_tokenize_operations_produce_unique_tokens() {
// Birthday-paradox-level test: 1000 tokens, all distinct.
let vault = InMemoryVault::ephemeral("uniq");
let mut tokens = Vec::with_capacity(1000);
for _ in 0..1000 {
tokens.push(
vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap(),
);
}
let mut seen = std::collections::HashSet::new();
for t in &tokens {
assert!(seen.insert(t.as_str().to_owned()), "duplicate token: {t:?}");
}
}
#[test]
fn exists_check_does_not_decrypt() {
// The orchestrator can probe whether a token is valid before
// bothering to route a payment, without revealing the PAN.
let vault = InMemoryVault::ephemeral("probe");
let token = vault
.tokenize(card_visa(), TokenizationPolicy::default())
.unwrap();
assert!(vault.exists(&token).unwrap());
let fake = VaultRef::new("tok_v7_definitely_not_a_real_token");
assert!(!vault.exists(&fake).unwrap());
}
#[test]
fn vault_trait_object_routes_via_dyn() {
// The orchestrator holds Box<dyn Vault> and doesn't know which
// implementation it has — same pattern as Phases 4-6.
fn issue_token(v: &dyn Vault, card: CardData) -> VaultRef {
v.tokenize(card, TokenizationPolicy::default()).unwrap()
}
let vault = InMemoryVault::ephemeral("dyn");
let token = issue_token(&vault, card_visa());
assert!(token.as_str().starts_with("tok_v7_"));
}
#[test]
fn policy_helpers_compose_correctly() {
// The single-use helper produces a token that is consumed once.
let vault = InMemoryVault::ephemeral("policy");
let p = TokenizationPolicy::single_use(60);
assert_eq!(p.lifetime, TokenLifetime::SingleUse);
let token = vault.tokenize(card_visa(), p).unwrap();
assert!(vault.detokenize(&token).is_ok());
assert!(matches!(
vault.detokenize(&token),
Err(Error::AlreadyConsumed)
));
}