open-standards/openpay/crates/op-screening/tests/integration.rs
Transaction Science 2d7de0396c
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 10s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m4s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m13s
openpay: a PEP match is recorded as a PEP match, not an OFAC sanctions hit
op-connect's PEP index tagged every entry `SanctionsList::OfacConsolidated`,
because the enum had no place for a PEP list. Its comment said the operator
surface "should not surface this". But a PEP index is a `Screener`, and every
screen appends `source_list.label()` to the screener's signed, hash-chained
audit log. So each PEP match was written into the tamper-evident compliance
record as "OFAC Consolidated": a sanctions designation (block, report) where
there was a due-diligence flag. An adverse-media index had the same problem:
it too could only tag its entries with a sanctions authority.

- `SanctionsList` is now `WatchList`, the term `ScreenerConfig::watchlist_filter`
  already used. It gains `Pep` ("PEP list") and `AdverseMedia` ("Adverse
  media"), and `is_sanctions()`, which is false for exactly those two. Its
  docs say why a match must never be recorded under a list it did not come
  from. A deprecated `SanctionsList` alias keeps the old name compiling.
- `build_pep_index_from_names` tags its entries `WatchList::Pep`.
- Serialized forms are unchanged: the enum's variant names stay the same, so
  stored indexes still load. No published vector or doc carries the label.

Tests:
- `a_pep_match_is_audited_as_a_pep_match_not_a_sanctions_match` screens a PEP
  fixture and checks that the audit chain verifies and that its row names
  "PEP list".
- `only_the_authorities_are_sanctions_lists` and `every_list_has_its_own_label`
  pin the kinds and labels. An exhaustive match makes a new list fail to
  compile until it is classified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:47:42 -04:00

134 lines
4.2 KiB
Rust

//! End-to-end integration tests against fixture data.
//!
//! Tests never hit a real list endpoint. The parser tests under
//! `src/updater.rs` cover schema-level concerns; this file covers
//! the cross-module path: parse fixture → build index → screen →
//! audit-log integrity.
use chrono::Utc;
use op_screening::{
AuditLog, EntityType, OfacUpdater, SanctionedEntity, SanctionsIndex, WatchList,
ScreenDecision, ScreenRequest, Screener, ScreenerConfig,
};
fn synthetic_entities(n: usize) -> Vec<SanctionedEntity> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
out.push(SanctionedEntity {
id: format!("id-{i:04}"),
name: format!("Person Number {i}"),
name_aliases: vec![format!("Alias of {i}")],
entity_type: EntityType::Individual,
dob: None,
place_of_birth: None,
addresses: vec![],
nationalities: vec![],
identifications: vec![],
programs: vec!["TEST".to_string()],
last_updated: Utc::now(),
source_list: WatchList::OfacSdn,
});
}
// Splice in a single distinguished record we can hit.
out.push(SanctionedEntity {
id: "TARGET".to_string(),
name: "Vladimir Konstantin Petrov".to_string(),
name_aliases: vec!["V.K. Petrov".to_string(), "Vlad Petrov".to_string()],
entity_type: EntityType::Individual,
dob: None,
place_of_birth: None,
addresses: vec![],
nationalities: vec![],
identifications: vec![],
programs: vec!["RUSSIA-EO14024".to_string()],
last_updated: Utc::now(),
source_list: WatchList::OfacSdn,
});
out
}
#[tokio::test]
async fn end_to_end_near_match_hits() {
let entities = synthetic_entities(1_000);
let idx = SanctionsIndex::build(entities);
assert_eq!(idx.len(), 1_001);
let screener = Screener::new(
idx,
ScreenerConfig {
threshold: 0.80,
..ScreenerConfig::default()
},
AuditLog::new(),
);
// Near-match (single-character typo) of the distinguished record.
let r = screener
.screen(&ScreenRequest {
name: "Vladimir Konstantin Petrove".to_string(),
dob: None,
address: None,
additional_ids: vec![],
})
.await
.expect("screen ok");
assert!(!r.hits.is_empty(), "expected near-match to land");
assert_eq!(r.hits[0].entity.id, "TARGET");
assert!(matches!(
r.decision,
ScreenDecision::Hit | ScreenDecision::AmbiguousNeedsReview
));
}
#[tokio::test]
async fn end_to_end_missing_returns_clear() {
let entities = synthetic_entities(1_000);
let idx = SanctionsIndex::build(entities);
let screener = Screener::new(idx, ScreenerConfig::default(), AuditLog::new());
let r = screener
.screen(&ScreenRequest {
name: "Definitively Not Anyone Sanctioned".to_string(),
dob: None,
address: None,
additional_ids: vec![],
})
.await
.expect("screen ok");
assert_eq!(r.decision, ScreenDecision::Clear);
assert!(r.hits.is_empty());
}
#[tokio::test]
async fn audit_log_records_every_call_and_verifies() {
let entities = synthetic_entities(50);
let idx = SanctionsIndex::build(entities);
let screener = Screener::new(idx, ScreenerConfig::default(), AuditLog::new());
for _ in 0..5 {
screener
.screen(&ScreenRequest {
name: "Some Random Name".to_string(),
dob: None,
address: None,
additional_ids: vec![],
})
.await
.expect("screen ok");
}
screener.with_audit_log(|log| {
assert_eq!(log.entries().len(), 5);
log.verify().expect("audit chain intact");
});
}
#[test]
fn parse_fixture_ofac() {
let xml = include_str!("fixtures/sdn_sample.xml");
let parsed = OfacUpdater::parse_sdn_xml(xml).expect("parse fixture");
assert!(!parsed.is_empty(), "fixture must parse to >=1 entity");
// At least one entry must carry a program.
assert!(parsed.iter().any(|p| !p.programs.is_empty()));
}