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(
"Bob Matsuoka".to_string(),
vec!["bob.matsuoka@duettoresearch.com".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Bob M", "bob.matsuoka@otherdomain.com");
assert_eq!(n, "Bob Matsuoka");
assert_eq!(e, "bob.matsuoka@duettoresearch.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(
"Bob Matsuoka".to_string(),
vec!["bob.matsuoka@co.com".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, _e) = r.resolve("Bob M", "bobm@unknown.test");
assert_eq!(n, "Bob Matsuoka");
}
#[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(
"Andre Ramos".to_string(),
vec![
"andre.ramos@duettoresearch.com".into(),
"129991831+andreramosduetto@users.noreply.github.com".into(),
"andre@personal.dev".into(),
],
);
let r = IdentityResolver::from_alias_map(&map);
let (n1, e1) = r.resolve("Andre Ramos", "andre.ramos@duettoresearch.com");
let (n2, e2) = r.resolve(
"andreramosduetto",
"129991831+andreramosduetto@users.noreply.github.com",
);
let (n3, e3) = r.resolve("A. Ramos", "andre@personal.dev");
assert_eq!(n1, "Andre Ramos");
assert_eq!(n2, "Andre Ramos");
assert_eq!(n3, "Andre Ramos");
assert_eq!(e1, "andre.ramos@duettoresearch.com");
assert_eq!(e2, "andre.ramos@duettoresearch.com");
assert_eq!(e3, "andre.ramos@duettoresearch.com");
}
#[test]
fn duetto_contractors_config_resolves() {
let tmpdir = tempfile::TempDir::new().expect("create tmp");
let tmp = tmpdir.path();
let aliases_yaml = r#"
developers:
- name: "Andre Ramos"
primary_email: "andre.ramos@duettoresearch.com"
aliases:
- "129991831+andreramosduetto@users.noreply.github.com"
- name: "Akash Arora"
primary_email: "akash.arora@duettoresearch.com"
aliases:
- "Akash.Arora-c@duettoresearch.com"
- "akash-duetto"
- name: "Janga Vinod Kumar Reddy"
primary_email: "janga.reddy@duettoresearch.com"
aliases:
- "jangareddy-duetto"
- "164324948+jangareddy-duetto@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("duetto-contractors.yaml");
std::fs::write(&config_path, config_yaml).expect("write config");
let cfg =
crate::core::config::Config::load(&config_path).expect("load duetto-contractors yaml");
let r = IdentityResolver::from_config(&cfg);
let (n, _) = r.resolve("whoever", "andre.ramos@duettoresearch.com");
assert_eq!(n, "Andre Ramos");
let (n, _) = r.resolve("whoever", "Akash.Arora-c@duettoresearch.com");
assert_eq!(n, "Akash Arora");
let (n, _) = r.resolve("jangareddy-duetto", "noise@nowhere.test");
assert_eq!(n, "Janga Vinod Kumar Reddy");
}
#[test]
fn normalize_for_fuzzy_basic() {
assert_eq!(normalize_for_fuzzy("Bob.Matsuoka"), "bob matsuoka");
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@DUETTORESEARCH.COM",
"duettoresearch.com"
));
assert!(email_domain_matches(
"a@duettoresearch.com",
"@duettoresearch.com"
));
assert!(!email_domain_matches("a@other.com", "duettoresearch.com"));
assert!(!email_domain_matches("invalid-email", "duettoresearch.com"));
assert!(!email_domain_matches("a@duettoresearch.com", ""));
}
#[test]
fn canonical_domain_prefers_org_email_for_team_member() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Alice Org".into(),
email: "alice@duettoresearch.com".into(),
aliases: vec!["alice@personal.com".into()],
}],
aliases: HashMap::new(),
canonical_domain: Some("duettoresearch.com".into()),
};
let r = IdentityResolver::new(Some(&team));
let (_, e) = r.resolve("Alice Org", "alice@personal.com");
assert_eq!(e, "alice@duettoresearch.com");
assert_eq!(r.canonical_domain(), Some("duettoresearch.com"));
}
#[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("duettoresearch.com".into()),
};
let r = IdentityResolver::new(Some(&team));
let db = Database::open_in_memory().expect("db");
let _ = r
.upsert_author(&db, "Bob Matsuoka", "bob@duettoresearch.com")
.expect("seed");
let id = r
.upsert_author(&db, "Bob Matsuoka", "bob@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, "bob@duettoresearch.com");
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_name = 'Bob Matsuoka'",
[],
|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@Duetto.COM"), "duetto.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@duettoresearch.com".into(),
aliases: vec![],
}],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
assert!(
jaro_winkler("ops+snyk@duettoresearch.com", "jenkins@duettoresearch.com")
>= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity should exceed the threshold"
);
let (name, email) = r.resolve("Snyk Bot", "ops+snyk@duettoresearch.com");
assert_ne!(
name, "Jenkins CI",
"Snyk bot must not be misattributed to Jenkins CI (#2253)"
);
assert_eq!(name, "Snyk Bot");
assert_eq!(email, "ops+snyk@duettoresearch.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: "Crislaine Tripoli"
primary_email: "crislaine.tripoli@duettoresearch.com"
aliases: []
- name: "Ravi Pandey"
primary_email: "ravi.pandey@duettoresearch.com"
aliases: []
- name: "Gaurav Sharma"
primary_email: "gaurav.sharma@duettoresearch.com"
aliases: []
- name: "Joshua Lepage"
primary_email: "joshua.lepage@duettoresearch.com"
aliases: []
- name: "Joshua McCartney"
primary_email: "joshua.mccartney@duettoresearch.com"
aliases: []
- name: "Akash Arora"
primary_email: "akash.arora@duettoresearch.com"
aliases:
- "Akash.Arora-c@duettoresearch.com"
- "akash-duetto"
- name: "Andre Ramos"
primary_email: "andre.ramos@duettoresearch.com"
aliases:
- "129991831+andreramosduetto@users.noreply.github.com"
"#;
const ISSUE_4251_MISATTRIBUTIONS: &[(&str, &str, &str)] = &[
(
"Cristian Dominguez",
"cristian.dominguez@duettoresearch.com",
"Crislaine Tripoli",
),
(
"Ravi Chandrasekaran",
"ravi.chandrasekaran@duettoresearch.com",
"Ravi Pandey",
),
(
"Gauri Saykar",
"gauri.saykar@duettoresearch.com",
"Gaurav Sharma",
),
("Josh Taylor", "josh@duettoresearch.com", "Joshua Lepage"),
("Joseph Ku", "joseph.ku@duettoresearch.com", "Joshua Lepage"),
];
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", "crislaine.tripoli@duettoresearch.com"),
(
"Crislaine Tripoli".to_string(),
"crislaine.tripoli@duettoresearch.com".to_string()
),
"non-vacuity: the roster must actually be loaded, otherwise a \
pass-through result proves nothing"
);
assert_eq!(
r.resolve("akash-duetto", "noise@nowhere.test").0,
"Akash Arora",
"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", "Akash.Arora-c@duettoresearch.com");
assert_eq!(n, "Akash Arora");
assert_eq!(e, "akash.arora@duettoresearch.com");
let (n, e) = r.resolve("akash-duetto", "noise@nowhere.test");
assert_eq!(n, "Akash Arora");
assert_eq!(e, "akash.arora@duettoresearch.com");
let (n, e) = r.resolve(
"andreramosduetto",
"129991831+andreramosduetto@users.noreply.github.com",
);
assert_eq!(n, "Andre Ramos");
assert_eq!(e, "andre.ramos@duettoresearch.com");
let (n, e) = r.resolve("whoever", "CRISLAINE.TRIPOLI@DUETTORESEARCH.COM");
assert_eq!(n, "Crislaine Tripoli");
assert_eq!(e, "crislaine.tripoli@duettoresearch.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("Gauri Saykar", "gauri.saykar@duettoresearch.com");
assert_eq!(n, "Gaurav Sharma");
}
#[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(
"Cristian Dominguez",
"cristian.dominguez@duettoresearch.com"
),
(
"Cristian Dominguez".to_string(),
"cristian.dominguez@duettoresearch.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:
"Gaurav Sharma":
- "gaurav.sharma@duettoresearch.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("Gauri Saykar", "gauri.saykar@duettoresearch.com");
assert_eq!(n, "Gaurav Sharma");
}
#[test]
fn explicit_opt_out_disables_fuzzy_without_aliases_file() {
let yaml = r#"
version: "1.0"
fuzzy_identity_fallback: false
developer_aliases:
"Gaurav Sharma":
- "gaurav.sharma@duettoresearch.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("Gauri Saykar", "gauri.saykar@duettoresearch.com");
assert_eq!(n, "Gauri Saykar");
assert_eq!(e, "gauri.saykar@duettoresearch.com");
}
#[test]
fn no_aliases_file_keeps_fuzzy_enabled_by_default() {
let yaml = r#"
version: "1.0"
developer_aliases:
"Bob Matsuoka":
- "bob.matsuoka@duettoresearch.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("Bob M", "bob.matsuoka@otherdomain.com");
assert_eq!(n, "Bob Matsuoka");
}
#[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@duettoresearch.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@duettoresearch.com", "jenkins@duettoresearch.com")
>= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity clears the threshold"
);
let (n, e) = r.resolve("Snyk Bot", "ops+snyk@duettoresearch.com");
assert_eq!(n, "Snyk Bot");
assert_eq!(e, "ops+snyk@duettoresearch.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: "duettoresearch.com"
members:
- name: "Alice"
email: "alice@duettoresearch.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("duettoresearch.com"));
}