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
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]>
134 lines
4.2 KiB
Rust
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()));
|
|
}
|