use super::*;
use crate::core::config::{TeamConfig, TeamMember};
use std::collections::HashMap;
fn make_team() -> TeamConfig {
let mut aliases = HashMap::new();
aliases.insert("bobby".into(), "Bob Smith".into());
TeamConfig {
members: vec![TeamMember {
name: "Bob Smith".into(),
email: "bob@example.com".into(),
aliases: vec!["bsmith@example.com".into()],
}],
aliases,
canonical_domain: None,
}
}
#[test]
fn exact_email_alias_match() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("Whoever", "bsmith@example.com");
assert_eq!(n, "Bob Smith");
assert_eq!(e, "bob@example.com");
}
#[test]
fn exact_name_alias_match() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("bobby", "x@y.com");
assert_eq!(n, "Bob Smith");
assert_eq!(e, "bob@example.com");
}
#[test]
fn fuzzy_match_canonical_name() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, _e) = r.resolve("Bob Smyth", "unknown@elsewhere.com");
assert_eq!(n, "Bob Smith");
}
#[test]
fn no_match_returns_input() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("Zelda Q", "zelda@nowhere.test");
assert_eq!(n, "Zelda Q");
assert_eq!(e, "zelda@nowhere.test");
}
#[test]
fn empty_team_passthrough() {
let r = IdentityResolver::new(None);
let (n, e) = r.resolve("Anyone", "anyone@x.com");
assert_eq!(n, "Anyone");
assert_eq!(e, "anyone@x.com");
}
#[test]
fn all_aliases_registered() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Alice Smith".to_string(),
vec![
"alice@company.com".into(),
"alice.smith@personal.com".into(),
"asmith".into(), ],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("whoever", "alice@company.com");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n, e) = r.resolve("whoever", "alice.smith@personal.com");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n, e) = r.resolve("asmith", "noise@nowhere.test");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
}
#[test]
fn email_local_part_fuzzy_match() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Wesley Nakamura".to_string(),
vec!["wesley.nakamura@examplecorpresearch.com".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Wesley N", "wesley.nakamura@otherdomain.com");
assert_eq!(n, "Wesley Nakamura");
assert_eq!(e, "wesley.nakamura@examplecorpresearch.com");
}
#[test]
fn case_insensitive_email_lookup() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert("Alice Smith".to_string(), vec!["alice@company.com".into()]);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Whoever", "ALICE@COMPANY.COM");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n2, e2) = r.resolve("WhoEver", "Alice@Company.Com");
assert_eq!(n2, "Alice Smith");
assert_eq!(e2, "alice@company.com");
}
#[test]
fn short_name_fuzzy() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Wesley Nakamura".to_string(),
vec!["wesley.nakamura@co.com".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, _e) = r.resolve("Wesley N", "wesleyn@unknown.test");
assert_eq!(n, "Wesley Nakamura");
}
#[test]
fn unknown_author_passthrough() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert("Alice Smith".to_string(), vec!["alice@company.com".into()]);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Zelda Q", "zelda@nowhere.test");
assert_eq!(n, "Zelda Q");
assert_eq!(e, "zelda@nowhere.test");
}
#[test]
fn multiple_emails_same_person() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Victor Alarcon".to_string(),
vec![
"victor.alarcon@examplecorpresearch.com".into(),
"583920174+victoralarconexample@users.noreply.github.com".into(),
"victor@personal.dev".into(),
],
);
let r = IdentityResolver::from_alias_map(&map);
let (n1, e1) = r.resolve("Victor Alarcon", "victor.alarcon@examplecorpresearch.com");
let (n2, e2) = r.resolve(
"victoralarconexample",
"583920174+victoralarconexample@users.noreply.github.com",
);
let (n3, e3) = r.resolve("V. Alarcon", "victor@personal.dev");
assert_eq!(n1, "Victor Alarcon");
assert_eq!(n2, "Victor Alarcon");
assert_eq!(n3, "Victor Alarcon");
assert_eq!(e1, "victor.alarcon@examplecorpresearch.com");
assert_eq!(e2, "victor.alarcon@examplecorpresearch.com");
assert_eq!(e3, "victor.alarcon@examplecorpresearch.com");
}
#[test]
fn examplecorp_contractors_config_resolves() {
let tmpdir = tempfile::TempDir::new().expect("create tmp");
let tmp = tmpdir.path();
let aliases_yaml = r#"
developers:
- name: "Victor Alarcon"
primary_email: "victor.alarcon@examplecorpresearch.com"
aliases:
- "583920174+victoralarconexample@users.noreply.github.com"
- name: "Devraj Kapadia"
primary_email: "devraj.kapadia@examplecorpresearch.com"
aliases:
- "Devraj.Kapadia-c@examplecorpresearch.com"
- "devraj-example"
- name: "Arun Deepak Kumar Nair"
primary_email: "arun.nair@examplecorpresearch.com"
aliases:
- "arunnair-example"
- "728104553+arunnair-example@users.noreply.github.com"
"#;
let aliases_path = tmp.join("aliases.yaml");
std::fs::write(&aliases_path, aliases_yaml).expect("write aliases");
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
aliases_path.to_string_lossy()
);
let config_path = tmp.join("examplecorp-contractors.yaml");
std::fs::write(&config_path, config_yaml).expect("write config");
let cfg =
crate::core::config::Config::load(&config_path).expect("load examplecorp-contractors yaml");
let r = IdentityResolver::from_config(&cfg);
let (n, _) = r.resolve("whoever", "victor.alarcon@examplecorpresearch.com");
assert_eq!(n, "Victor Alarcon");
let (n, _) = r.resolve("whoever", "Devraj.Kapadia-c@examplecorpresearch.com");
assert_eq!(n, "Devraj Kapadia");
let (n, _) = r.resolve("arunnair-example", "noise@nowhere.test");
assert_eq!(n, "Arun Deepak Kumar Nair");
}
#[test]
fn normalize_for_fuzzy_basic() {
assert_eq!(normalize_for_fuzzy("Wesley.Nakamura"), "wesley nakamura");
assert_eq!(normalize_for_fuzzy("alice_smith-c"), "alice smith c");
assert_eq!(normalize_for_fuzzy(" Foo Bar "), "foo bar");
}
#[test]
fn email_local_part_basic() {
assert_eq!(email_local_part("Bob@Example.COM"), "bob");
assert_eq!(email_local_part("no-at-symbol"), "no-at-symbol");
}
#[test]
fn email_domain_matches_basic() {
assert!(email_domain_matches(
"a@EXAMPLECORPRESEARCH.COM",
"examplecorpresearch.com"
));
assert!(email_domain_matches(
"a@examplecorpresearch.com",
"@examplecorpresearch.com"
));
assert!(!email_domain_matches(
"a@other.com",
"examplecorpresearch.com"
));
assert!(!email_domain_matches(
"invalid-email",
"examplecorpresearch.com"
));
assert!(!email_domain_matches("a@examplecorpresearch.com", ""));
}
#[test]
fn canonical_domain_prefers_org_email_for_team_member() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Alice Org".into(),
email: "alice@examplecorpresearch.com".into(),
aliases: vec!["alice@personal.com".into()],
}],
aliases: HashMap::new(),
canonical_domain: Some("examplecorpresearch.com".into()),
};
let r = IdentityResolver::new(Some(&team));
let (_, e) = r.resolve("Alice Org", "alice@personal.com");
assert_eq!(e, "alice@examplecorpresearch.com");
assert_eq!(r.canonical_domain(), Some("examplecorpresearch.com"));
}
#[test]
fn a_merged_away_email_routes_to_its_canonical_row() {
use crate::core::db::Database;
use rusqlite::params;
let db = Database::open_in_memory().expect("open db");
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email, aliases) \
VALUES ('Alice', 'alice@corp.com', ?1)",
params![r#"["alice@personal.com"]"#],
)
.expect("seed destination");
let dst: i64 = db
.connection()
.query_row(
"SELECT id FROM authors WHERE canonical_email = 'alice@corp.com'",
[],
|r| r.get(0),
)
.expect("dst id");
let r = IdentityResolver::new(None);
let id = r
.upsert_author(&db, "Alice", "alice@personal.com")
.expect("upsert");
assert_eq!(id, dst, "the alias must route to its canonical identity");
let upper = r
.upsert_author(&db, "Alice", "Alice@Personal.com")
.expect("upsert");
assert_eq!(upper, dst);
let rows: i64 = db
.connection()
.query_row("SELECT COUNT(*) FROM authors", [], |r| r.get(0))
.expect("count");
assert_eq!(rows, 1, "no row may be re-created for a merged-away email");
let other = r
.upsert_author(&db, "Bob", "not-alice@personal.com.example")
.expect("upsert");
assert_ne!(other, dst, "a substring match must not claim the identity");
}
#[test]
fn configured_canonical_domain_normalises_the_value() {
use crate::collect::identity::resolver::configured_canonical_domain;
use crate::core::config::Config;
let mut config = Config::default();
assert_eq!(configured_canonical_domain(&config), None, "no team block");
config.team = Some(TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some(" @Corp.COM ".into()),
});
assert_eq!(
configured_canonical_domain(&config),
Some("corp.com".to_string())
);
config.team = Some(TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some(" ".into()),
});
assert_eq!(
configured_canonical_domain(&config),
None,
"empty is absent"
);
}
#[test]
fn canonical_domain_routes_new_personal_email_to_existing_org_row() {
use crate::core::db::Database;
use rusqlite::params;
let team = TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some("examplecorpresearch.com".into()),
};
let r = IdentityResolver::new(Some(&team));
let db = Database::open_in_memory().expect("db");
let _ = r
.upsert_author(&db, "Wesley Nakamura", "wesley@examplecorpresearch.com")
.expect("seed");
let id = r
.upsert_author(&db, "Wesley Nakamura", "wesley@personal.com")
.expect("upsert");
let stored_email: String = db
.connection()
.query_row(
"SELECT canonical_email FROM authors WHERE id = ?1",
params![id],
|row| row.get(0),
)
.expect("lookup");
assert_eq!(stored_email, "wesley@examplecorpresearch.com");
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_name = 'Wesley Nakamura'",
[],
|row| row.get(0),
)
.expect("count");
assert_eq!(count, 1);
}
#[test]
fn canonical_domain_absent_falls_back_to_first_seen_email() {
use crate::core::db::Database;
let r = IdentityResolver::new(None);
assert_eq!(r.canonical_domain(), None);
let db = Database::open_in_memory().expect("db");
let _ = r
.upsert_author(&db, "Carol", "carol@personal.com")
.expect("seed");
let _ = r
.upsert_author(&db, "Carol", "carol@work.com")
.expect("upsert");
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_name = 'Carol'",
[],
|row| row.get(0),
)
.expect("count");
assert_eq!(count, 2);
}
#[test]
fn email_domain_basic() {
assert_eq!(
email_domain("ops+snyk@ExampleCorpResearch.COM"),
"examplecorpresearch.com"
);
assert_eq!(email_domain("no-at-symbol"), "");
assert_eq!(email_domain("weird@name@example.org"), "example.org");
}
#[test]
fn tier3_does_not_merge_bots_sharing_domain_suffix() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Jenkins CI".into(),
email: "jenkins@examplecorpresearch.com".into(),
aliases: vec![],
}],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
assert!(
jaro_winkler(
"ops+snyk@examplecorpresearch.com",
"jenkins@examplecorpresearch.com"
) >= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity should exceed the threshold"
);
let (name, email) = r.resolve("Jordan Sampleauthor", "ops+snyk@examplecorpresearch.com");
assert_ne!(
name, "Jenkins CI",
"Snyk bot must not be misattributed to Jenkins CI (#2253)"
);
assert_eq!(name, "Jordan Sampleauthor");
assert_eq!(email, "ops+snyk@examplecorpresearch.com");
}
#[test]
fn tier3_still_matches_same_domain_near_identical_local_parts() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Alice Cooper".into(),
email: "alice.cooper@acme.com".into(),
aliases: vec![],
}],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
let (name, email) = r.resolve("acoopr", "alice.coopr@acme.com");
assert_eq!(name, "Alice Cooper");
assert_eq!(email, "alice.cooper@acme.com");
}
const ISSUE_4251_ALIASES_YAML: &str = r#"
developers:
- name: "Kristina Amberly"
primary_email: "kristina.amberly@examplecorpresearch.com"
aliases: []
- name: "Milan Advani"
primary_email: "milan.advani@examplecorpresearch.com"
aliases: []
- name: "Rohan Deshmukh"
primary_email: "rohan.deshmukh@examplecorpresearch.com"
aliases: []
- name: "Jonathan Whitfield"
primary_email: "jonathan.whitfield@examplecorpresearch.com"
aliases: []
- name: "Jordan Mercer"
primary_email: "jordan.mercer@examplecorpresearch.com"
aliases: []
- name: "Devraj Kapadia"
primary_email: "devraj.kapadia@examplecorpresearch.com"
aliases:
- "Devraj.Kapadia-c@examplecorpresearch.com"
- "devraj-example"
- name: "Victor Alarcon"
primary_email: "victor.alarcon@examplecorpresearch.com"
aliases:
- "583920174+victoralarconexample@users.noreply.github.com"
"#;
const ISSUE_4251_MISATTRIBUTIONS: &[(&str, &str, &str)] = &[
(
"Kristian Voss",
"kristian.voss@examplecorpresearch.com",
"Kristina Amberly",
),
(
"Milan Venkataraman",
"milan.venkataraman@examplecorpresearch.com",
"Milan Advani",
),
(
"Rohana Deshkar",
"rohana.deshkar@examplecorpresearch.com",
"Rohan Deshmukh",
),
(
"Jonah Carver",
"jonah@examplecorpresearch.com",
"Jonathan Whitfield",
),
(
"Jonah Wheeler",
"jonah.wheeler@examplecorpresearch.com",
"Jonathan Whitfield",
),
];
fn resolver_from_aliases_file(extra_config_yaml: &str) -> (IdentityResolver, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let aliases_path = tmp.path().join("aliases.yaml");
std::fs::write(&aliases_path, ISSUE_4251_ALIASES_YAML).unwrap();
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n{extra_config_yaml}",
aliases_path.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
(IdentityResolver::from_config(&cfg), tmp)
}
fn assert_roster_loaded(r: &IdentityResolver) {
assert_eq!(
r.resolve("whoever", "kristina.amberly@examplecorpresearch.com"),
(
"Kristina Amberly".to_string(),
"kristina.amberly@examplecorpresearch.com".to_string()
),
"non-vacuity: the roster must actually be loaded, otherwise a \
pass-through result proves nothing"
);
assert_eq!(
r.resolve("devraj-example", "noise@nowhere.test").0,
"Devraj Kapadia",
"non-vacuity: declared login-handle aliases must be present"
);
}
#[test]
fn aliases_file_disables_tier34_name_fuzzy() {
let (r, _tmp) = resolver_from_aliases_file("");
assert!(
!r.fuzzy_fallback(),
"supplying an aliases_file must disable the fuzzy fallback"
);
assert_roster_loaded(&r);
let mut observed: Vec<String> = Vec::new();
let mut expected: Vec<String> = Vec::new();
for (name, email, wrong_target) in ISSUE_4251_MISATTRIBUTIONS {
let raw_name = jaro_winkler(&name.to_lowercase(), &wrong_target.to_lowercase());
let norm_local = jaro_winkler(
&normalize_for_fuzzy(&email_local_part(email)),
&normalize_for_fuzzy(wrong_target),
);
assert!(
raw_name >= DEFAULT_SIMILARITY_THRESHOLD
|| norm_local >= NORMALIZED_SIMILARITY_THRESHOLD,
"precondition: {name} vs {wrong_target} must be fuzzy-reachable \
(tier3 name={raw_name:.4}, tier4 normalized={norm_local:.4})"
);
let (resolved_name, resolved_email) = r.resolve(name, email);
observed.push(format!(
"{name} <{email}> => {resolved_name} <{resolved_email}>"
));
expected.push(format!("{name} <{email}> => {name} <{email}>"));
}
assert_eq!(
observed, expected,
"#4251: undeclared authors must pass through, not be guessed onto the \
nearest-spelled roster member"
);
}
#[test]
fn aliases_file_gate_preserves_tier12_declared_resolutions() {
let (r, _tmp) = resolver_from_aliases_file("");
let (n, e) = r.resolve("whoever", "Devraj.Kapadia-c@examplecorpresearch.com");
assert_eq!(n, "Devraj Kapadia");
assert_eq!(e, "devraj.kapadia@examplecorpresearch.com");
let (n, e) = r.resolve("devraj-example", "noise@nowhere.test");
assert_eq!(n, "Devraj Kapadia");
assert_eq!(e, "devraj.kapadia@examplecorpresearch.com");
let (n, e) = r.resolve(
"victoralarconexample",
"583920174+victoralarconexample@users.noreply.github.com",
);
assert_eq!(n, "Victor Alarcon");
assert_eq!(e, "victor.alarcon@examplecorpresearch.com");
let (n, e) = r.resolve("whoever", "KRISTINA.AMBERLY@EXAMPLECORPRESEARCH.COM");
assert_eq!(n, "Kristina Amberly");
assert_eq!(e, "kristina.amberly@examplecorpresearch.com");
}
#[test]
fn explicit_opt_in_reenables_fuzzy_with_aliases_file() {
let (r, _tmp) = resolver_from_aliases_file("fuzzy_identity_fallback: true\n");
assert!(
r.fuzzy_fallback(),
"explicit opt-in must re-enable Tier 3/4"
);
assert_roster_loaded(&r);
let (n, _) = r.resolve("Rohana Deshkar", "rohana.deshkar@examplecorpresearch.com");
assert_eq!(n, "Rohan Deshmukh");
}
#[test]
#[should_panic(expected = "non-vacuity")]
fn non_vacuity_control_rejects_zero_member_resolver() {
let empty: HashMap<String, Vec<String>> = HashMap::new();
let r = IdentityResolver::from_alias_map(&empty);
assert_eq!(
r.resolve("Kristian Voss", "kristian.voss@examplecorpresearch.com"),
(
"Kristian Voss".to_string(),
"kristian.voss@examplecorpresearch.com".to_string()
)
);
assert_roster_loaded(&r); }
#[test]
fn broken_aliases_file_keeps_fuzzy_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist.yaml");
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
missing.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
assert!(cfg.resolved_alias_map(cfg.config_dir()).is_err());
assert!(cfg.resolved_aliases().is_empty());
let r = IdentityResolver::from_config(&cfg);
assert!(
r.fuzzy_fallback(),
"a declared aliases_file that failed to load must not be treated as a \
comprehensive alias table"
);
}
#[test]
fn empty_aliases_file_keeps_fuzzy_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
let aliases_path = tmp.path().join("aliases.yaml");
std::fs::write(&aliases_path, "developers: []\n").unwrap();
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
aliases_path.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
assert!(cfg.resolved_alias_map(cfg.config_dir()).unwrap().is_empty());
let r = IdentityResolver::from_config(&cfg);
assert!(r.fuzzy_fallback());
}
#[test]
fn inline_developer_aliases_do_not_disable_fuzzy() {
let yaml = r#"
version: "1.0"
developer_aliases:
"Rohan Deshmukh":
- "rohan.deshmukh@examplecorpresearch.com"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
assert!(
!cfg.resolved_aliases().is_empty(),
"inline map must be loaded"
);
let r = IdentityResolver::from_config(&cfg);
assert!(
r.fuzzy_fallback(),
"#4251 gates on aliases_file, not on any non-empty alias map"
);
let (n, _) = r.resolve("Rohana Deshkar", "rohana.deshkar@examplecorpresearch.com");
assert_eq!(n, "Rohan Deshmukh");
}
#[test]
fn explicit_opt_out_disables_fuzzy_without_aliases_file() {
let yaml = r#"
version: "1.0"
fuzzy_identity_fallback: false
developer_aliases:
"Rohan Deshmukh":
- "rohan.deshmukh@examplecorpresearch.com"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
let r = IdentityResolver::from_config(&cfg);
assert!(!r.fuzzy_fallback());
let (n, e) = r.resolve("Rohana Deshkar", "rohana.deshkar@examplecorpresearch.com");
assert_eq!(n, "Rohana Deshkar");
assert_eq!(e, "rohana.deshkar@examplecorpresearch.com");
}
#[test]
fn no_aliases_file_keeps_fuzzy_enabled_by_default() {
let yaml = r#"
version: "1.0"
developer_aliases:
"Wesley Nakamura":
- "wesley.nakamura@examplecorpresearch.com"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
let r = IdentityResolver::from_config(&cfg);
assert!(r.fuzzy_fallback());
let (n, _) = r.resolve("Wesley N", "wesley.nakamura@otherdomain.com");
assert_eq!(n, "Wesley Nakamura");
}
#[test]
fn with_fuzzy_fallback_false_suppresses_tier34() {
let r = IdentityResolver::new(Some(&make_team()));
assert_eq!(
r.resolve("Bob Smyth", "unknown@elsewhere.com").0,
"Bob Smith"
);
let r = IdentityResolver::new(Some(&make_team())).with_fuzzy_fallback(false);
let (n, e) = r.resolve("Bob Smyth", "unknown@elsewhere.com");
assert_eq!(n, "Bob Smyth");
assert_eq!(e, "unknown@elsewhere.com");
assert_eq!(r.resolve("bobby", "x@y.com").0, "Bob Smith");
}
#[test]
fn issue_2253_domain_gate_intact_when_fuzzy_enabled() {
let team = TeamConfig {
members: vec![
TeamMember {
name: "Jenkins CI".into(),
email: "jenkins@examplecorpresearch.com".into(),
aliases: vec![],
},
TeamMember {
name: "Alice Cooper".into(),
email: "alice.cooper@acme.com".into(),
aliases: vec![],
},
],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
assert!(r.fuzzy_fallback(), "no aliases_file → fuzzy stays on");
assert!(
jaro_winkler(
"ops+snyk@examplecorpresearch.com",
"jenkins@examplecorpresearch.com"
) >= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity clears the threshold"
);
let (n, e) = r.resolve("Jordan Sampleauthor", "ops+snyk@examplecorpresearch.com");
assert_eq!(n, "Jordan Sampleauthor");
assert_eq!(e, "ops+snyk@examplecorpresearch.com");
let (n, e) = r.resolve("acoopr", "alice.coopr@acme.com");
assert_eq!(n, "Alice Cooper");
assert_eq!(e, "alice.cooper@acme.com");
}
#[test]
fn canonical_domain_read_from_config() {
let yaml = r#"
team:
canonical_domain: "examplecorpresearch.com"
members:
- name: "Alice"
email: "alice@examplecorpresearch.com"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).expect("parse");
let r = IdentityResolver::from_config(&cfg);
assert_eq!(r.canonical_domain(), Some("examplecorpresearch.com"));
}
#[test]
fn member_order_is_deterministic_across_rebuilds() {
fn build() -> IdentityResolver {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for (name, email) in [
("Alex Mockdata", "alex.mockdata@acme.com"),
("Alex Placeholder", "alex.placeholder@acme.com"),
("Alex Samplename", "alex.samplename@acme.com"),
("Alex Stubvalue", "alex.stubvalue@acme.com"),
("Alex Testfixture", "alex.testfixture@acme.com"),
("Alex Dummyrecord", "alex.dummyrecord@acme.com"),
] {
map.insert(name.to_string(), vec![email.to_string()]);
}
IdentityResolver::from_alias_map(&map)
}
let expected_members: Vec<(String, String)> = [
("Alex Dummyrecord", "alex.dummyrecord@acme.com"),
("Alex Mockdata", "alex.mockdata@acme.com"),
("Alex Placeholder", "alex.placeholder@acme.com"),
("Alex Samplename", "alex.samplename@acme.com"),
("Alex Stubvalue", "alex.stubvalue@acme.com"),
("Alex Testfixture", "alex.testfixture@acme.com"),
]
.iter()
.map(|(n, e)| ((*n).to_string(), (*e).to_string()))
.collect();
let expected_resolution = build().resolve("alex", "alex@unaffiliated.test");
for i in 0..100 {
let r = build();
assert_eq!(
r.members, expected_members,
"members order differs on rebuild {i}"
);
assert_eq!(
r.resolve("alex", "alex@unaffiliated.test"),
expected_resolution,
"resolution differs on rebuild {i}"
);
}
}
#[test]
fn fuzzy_tie_breaks_on_stable_key() {
fn team(order: [(&str, &str); 2]) -> TeamConfig {
TeamConfig {
members: order
.iter()
.map(|(n, e)| TeamMember {
name: (*n).to_string(),
email: (*e).to_string(),
aliases: vec![],
})
.collect(),
aliases: HashMap::new(),
canonical_domain: None,
}
}
let a = ("Chris Testperson", "a.testperson@acme.com");
let b = ("Chris Testperson", "b.testperson@acme.com");
let inbound_name = "Chris Testpersonn";
let inbound_email = "chris@unaffiliated.test";
let score_a = jaro_winkler(&inbound_name.to_lowercase(), &a.0.to_lowercase());
let score_b = jaro_winkler(&inbound_name.to_lowercase(), &b.0.to_lowercase());
assert_eq!(score_a, score_b, "precondition: scores must tie exactly");
assert!(
score_a >= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: the tied score must clear the Tier-3 threshold"
);
for order in [[a, b], [b, a]] {
let r = IdentityResolver::new(Some(&team(order)));
let (n, e) = r.resolve(inbound_name, inbound_email);
assert_eq!(n, "Chris Testperson");
assert_eq!(
e, "a.testperson@acme.com",
"tie must go to the lowest (email, name) key; declared order was {order:?}"
);
}
}
#[test]
fn alias_collision_resolves_deterministically() {
fn build() -> IdentityResolver {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Jane Roe".to_string(),
vec!["jane.roe@acme.com".into(), "jr".into()],
);
map.insert(
"John Roe".to_string(),
vec!["john.roe@acme.com".into(), "jr".into()],
);
IdentityResolver::from_alias_map(&map)
}
for i in 0..200 {
let (n, e) = build().resolve("jr", "jr@unaffiliated.test");
assert_eq!(n, "Jane Roe", "alias collision flipped on rebuild {i}");
assert_eq!(
e, "jane.roe@acme.com",
"alias collision flipped on rebuild {i}"
);
}
}
fn colliding_team_resolver() -> IdentityResolver {
let mut aliases = HashMap::new();
aliases.insert("JR2".to_string(), "John Roe".to_string());
aliases.insert("jr2".to_string(), "Jane Roe".to_string());
let team = TeamConfig {
members: vec![
TeamMember {
name: "Jane Roe".into(),
email: "jane.roe@acme.com".into(),
aliases: vec!["jr".into()],
},
TeamMember {
name: "John Roe".into(),
email: "john.roe@acme.com".into(),
aliases: vec!["jr".into()],
},
],
aliases,
canonical_domain: None,
};
IdentityResolver::new(Some(&team))
}
#[test]
fn team_member_alias_collision_goes_to_the_first_claimant() {
for i in 0..200 {
let (n, e) = colliding_team_resolver().resolve("jr", "jr@unaffiliated.test");
assert_eq!(
n, "Jane Roe",
"member alias collision flipped on rebuild {i}"
);
assert_eq!(
e, "jane.roe@acme.com",
"member alias collision flipped on rebuild {i}"
);
}
}
#[test]
fn team_free_form_alias_case_collision_goes_to_the_first_claimant() {
for i in 0..200 {
let (n, e) = colliding_team_resolver().resolve("jr2", "jr2@unaffiliated.test");
assert_eq!(
n, "John Roe",
"team.aliases case collision flipped on rebuild {i}"
);
assert_eq!(
e, "john.roe@acme.com",
"team.aliases case collision flipped on rebuild {i}"
);
}
}