package mapping import ( "strings" "testing" "time" "idsync/internal/feishu" ) func dept(id, parent, name string) *feishu.Department { return &feishu.Department{ID: id, ParentID: parent, Name: name} } func user(uid, name, email string, depts ...string) *feishu.User { return &feishu.User{ UnionID: "u-" + uid, Name: name, EnterpriseEmail: email, DepartmentIDs: depts, } } func options() BuildOptions { return BuildOptions{ UserOUDN: "ou=people,dc=example,dc=com", InactiveOUDN: "ou=Inactive,dc=example,dc=com", GroupRootDN: "ou=feishuroot,dc=example,dc=com", } } // TestBuildGroupOrderingIsParentFirst 是一个关键回归测试。 // // 分组必须按层级排序,父分组排在子分组之前。早期实现按部门 ID 字母序排序, // 当子部门的 ID 字母序恰好排在父部门之前时,创建子分组会因父分组尚不存在 // 而失败(LDAP No Such Object)。 func TestBuildGroupOrderingIsParentFirst(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{ // 子部门的 ID 字母序刻意排在父部门之前 dept("od-aaa-child", "od-root", "子部门"), dept("od-root", "0", "根部门"), dept("od-zzz-grandchild", "od-aaa-child", "孙部门"), }, } st := Build(snap, options()) seen := make(map[string]int) for i, g := range st.Groups { seen[g.DeptID] = i } for _, g := range st.Groups { if g.ParentID == "" || g.ParentID == "0" { continue } pi, ok := seen[g.ParentID] if !ok { continue // 父不在范围内 } ci := seen[g.DeptID] if pi > ci { t.Errorf("子分组 %s 排在父分组 %s 之前(%d > %d)", g.DeptID, g.ParentID, ci, pi) } } // 深度应正确反映层级 for _, g := range st.Groups { switch g.DeptID { case "od-root": if g.Depth != 0 { t.Errorf("根部门深度 = %d, 期望 0", g.Depth) } case "od-aaa-child": if g.Depth != 1 { t.Errorf("子部门深度 = %d, 期望 1", g.Depth) } case "od-zzz-grandchild": if g.Depth != 2 { t.Errorf("孙部门深度 = %d, 期望 2", g.Depth) } } } } // TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。 func TestBuildGroupDNReflectsNesting(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{ dept("od-root", "0", "Acme"), dept("od-rd", "od-root", "研发部"), dept("od-backend", "od-rd", "后端组"), }, } st := Build(snap, options()) rootDN := st.GroupByDeptID["od-root"].DN if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" { t.Errorf("根部门 DN = %q", rootDN) } rdDN := st.GroupByDeptID["od-rd"].DN if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" { t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN) } bdDN := st.GroupByDeptID["od-backend"].DN if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" { t.Errorf("后端组 DN = %q", bdDN) } } // TestBuildUsernameFromEnterpriseEmail 验证登录名来源。 func TestBuildUsernameFromEnterpriseEmail(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, Users: []*feishu.User{ user("1", "张三", "ZhangSan@Example.com", "od-root"), }, } st := Build(snap, options()) if len(st.Users) != 1 { t.Fatalf("用户数 = %d, 期望 1", len(st.Users)) } u := st.Users[0] // 登录名应小写且不含域名 if u.UID != "zhangsan" { t.Errorf("uid = %q, 期望 zhangsan(应取企业邮箱前缀并转小写)", u.UID) } if u.DN != "uid=zhangsan,ou=people,dc=example,dc=com" { t.Errorf("DN = %q", u.DN) } if u.InactiveDN != "uid=zhangsan,ou=Inactive,dc=example,dc=com" { t.Errorf("离职 DN = %q", u.InactiveDN) } // mail 必须是企业邮箱本身(保留原始大小写) if u.Mail != "ZhangSan@Example.com" { t.Errorf("mail = %q, 期望企业邮箱原值", u.Mail) } if u.DisplayName != "张三" { t.Errorf("displayName = %q, 期望 张三", u.DisplayName) } } // TestBuildSkipsUserWithoutEnterpriseEmail 验证缺少企业邮箱的用户不会被臆造登录名。 // // 这里刻意不断言「回落到个人邮箱」——那样的行为会产生不可预期、 // 且离职后仍然有效的登录名,因此必须跳过。 func TestBuildSkipsUserWithoutEnterpriseEmail(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, Users: []*feishu.User{ // 有个人邮箱但没有企业邮箱 { UnionID: "u-nomail", Name: "孙七", Email: "sq@personal.com", DepartmentIDs: []string{"od-root"}, }, }, } st := Build(snap, options()) if len(st.Users) != 0 { t.Errorf("缺少企业邮箱的用户不应被映射为登录名,实际生成 %d 个", len(st.Users)) } } // TestBuildMembership 验证成员关系按部门正确归集。 func TestBuildMembership(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{ dept("od-root", "0", "Acme"), dept("od-rd", "od-root", "研发部"), }, Users: []*feishu.User{ user("1", "张三", "zhangsan@example.com", "od-rd"), // 一人属多部门 user("2", "李四", "lisi@example.com", "od-root", "od-rd"), }, } st := Build(snap, options()) rd := st.GroupByDeptID["od-rd"] if _, ok := rd.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; !ok { t.Errorf("张三 未加入研发部") } if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { t.Errorf("李四 未加入研发部") } root := st.GroupByDeptID["od-root"] if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { t.Errorf("李四 未加入根部门") } if _, ok := root.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; ok { t.Errorf("张三 只属研发部,不应出现在根部门") } } // TestBuildOrphanDepartmentWarnsAndAttachesToRoot 验证父部门不在范围内时的处理。 // // 行为是「挂到分组根下并告警」,而不是静默跳过或静默挂错层级: // 静默跳过会让部门凭空消失,静默挂错层级会让人以为结构正确。 func TestBuildOrphanDepartmentWarnsAndAttachesToRoot(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{ // 父部门 od-missing 不在快照中 dept("od-orphan", "od-missing", "孤儿部门"), }, } st := Build(snap, options()) g, ok := st.GroupByDeptID["od-orphan"] if !ok { t.Fatalf("父部门不在范围内的部门仍应被同步(挂到分组根下)") } if want := "cn=guerbumen,ou=feishuroot,dc=example,dc=com"; g.DN != want { t.Errorf("孤儿部门 DN = %q, 期望 %q", g.DN, want) } found := false for _, w := range st.Warnings { if strings.Contains(w, "od-missing") && strings.Contains(w, "不在同步范围内") { found = true break } } if !found { t.Errorf("父部门不在范围内时应产生告警,实际告警: %v", st.Warnings) } } // TestBuildCycleProtection 验证部门树出现环时不会死循环。 func TestBuildCycleProtection(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{ // a 的父是 b,b 的父是 a:构造一个环 dept("od-a", "od-b", "A"), dept("od-b", "od-a", "B"), }, } done := make(chan *State, 1) go func() { done <- Build(snap, options()) }() select { case st := <-done: // 关键是能返回而不是卡死 if st == nil { t.Fatalf("Build 返回 nil") } case <-timeAfterSeconds(5): t.Fatalf("部门数据成环时 Build 未在 5 秒内返回,可能存在死循环") } } // TestToPinyin 验证部门名到 CN 的转换与清理。 func TestToPinyin(t *testing.T) { cases := []struct { in string want string }{ {"研发部", "yanfabu"}, {"后端组", "houduanzu"}, {"Acme", "Acme"}, {"IT Dept", "IT_Dept"}, {"", ""}, {" ", ""}, {"Finance-2", "Finance-2"}, } for _, c := range cases { if got := toPinyin(c.in); got != c.want { t.Errorf("toPinyin(%q) = %q, 期望 %q", c.in, got, c.want) } } } // TestCnNameFallsBackToDeptID 验证纯符号部门名不会产生空 CN。 func TestCnNameFallsBackToDeptID(t *testing.T) { got := cnName("★☆", "od-xyz") if got == "" { t.Errorf("cnName 不应返回空字符串") } if !strings.Contains(got, "od-xyz") { t.Errorf("无法转为拼音时应回落到部门 ID,实际: %q", got) } } // TestAttrsPreserveEmptyOptionalFields 验证可选字段为空时不会填充臆造值。 // // 这些属性在 LDAP 侧会走删除分支,而不是写入 "Default: xxx" 之类的占位值。 func TestBuildKeepsOptionalFieldsEmpty(t *testing.T) { snap := &feishu.Snapshot{ Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, Users: []*feishu.User{ { UnionID: "u-1", Name: "张三", EnterpriseEmail: "zhangsan@example.com", // Mobile/EmployeeNo/JobTitle/WorkStation 全部留空 DepartmentIDs: []string{"od-root"}, }, }, } st := Build(snap, options()) u := st.Users[0] if u.Mobile != "" { t.Errorf("mobile 应为空,实际 %q", u.Mobile) } if u.EmployeeNumber != "" { t.Errorf("employeeNumber 应为空,实际 %q", u.EmployeeNumber) } if u.DepartmentNumber != "" { t.Errorf("departmentNumber 应为空,实际 %q", u.DepartmentNumber) } if u.PostalAddress != "" { t.Errorf("postalAddress 应为空,实际 %q", u.PostalAddress) } // sn 是 inetOrgPerson 的必填项,必须有值 if u.SN == "" { t.Errorf("sn 不可为空(inetOrgPerson 必填)") } } // timeAfterSeconds 返回一个等待指定秒数后触发的通道,用于检验死循环。 func timeAfterSeconds(n int) <-chan struct{} { ch := make(chan struct{}) go func() { time.Sleep(time.Duration(n) * time.Second) close(ch) }() return ch }