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
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]>
237 lines
8 KiB
Rust
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)
|
|
));
|
|
}
|