diff --git a/internal/mapping/flat_test.go b/internal/mapping/flat_test.go new file mode 100644 index 0000000..6b2ef69 --- /dev/null +++ b/internal/mapping/flat_test.go @@ -0,0 +1,82 @@ +package mapping + +import ( + "strings" + "testing" + + "idsync/internal/feishu" +) + +// TestFlatDeptInfoParentChild 验证拍平拼音链:子部门链 = 父链.子拼音。 +func TestFlatDeptInfoParentChild(t *testing.T) { + depts := []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-rd", "od-root", "研发部"), + dept("od-backend", "od-rd", "后端组"), + } + out := FlatDeptInfo(depts, "dc=example,dc=com") + + if out["od-root"].Chain != "Acme" || out["od-root"].Level != 0 { + t.Errorf("根部门 = %+v", out["od-root"]) + } + if out["od-rd"].Chain != "Acme.yanfabu" || out["od-rd"].Level != 1 { + t.Errorf("研发部 = %+v", out["od-rd"]) + } + if out["od-backend"].Chain != "Acme.yanfabu.houduanzu" || out["od-backend"].Level != 2 { + t.Errorf("后端组 = %+v", out["od-backend"]) + } + wantDN := "cn=Acme.yanfabu.houduanzu,ou=groups,dc=example,dc=com" + if out["od-backend"].DN != wantDN { + t.Errorf("后端组 DN = %q, 期望 %q", out["od-backend"].DN, wantDN) + } +} + +// TestFlatDeptInfoFallsBackOnEmptyPinyin 验证纯符号部门名回落部门 ID,链非空且稳定。 +func TestFlatDeptInfoFallsBackOnEmptyPinyin(t *testing.T) { + depts := []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-sym", "od-root", "★☆"), + } + out := FlatDeptInfo(depts, "dc=example,dc=com") + if out["od-sym"].Chain != "Acme.dept-od-sym" { + t.Errorf("纯符号部门链 = %q, 期望回落部门 ID", out["od-sym"].Chain) + } + if !strings.Contains(out["od-sym"].DN, out["od-sym"].Chain) { + t.Errorf("DN 未包含 Chain: %q", out["od-sym"].DN) + } +} + +// TestFlatDeptInfoDisambiguatesCollision 验证同名部门消歧:链撞车时追加部门 ID,组名保持唯一。 +func TestFlatDeptInfoDisambiguatesCollision(t *testing.T) { + depts := []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-a", "od-root", "开发组"), + dept("od-b", "od-root", "开发组"), + } + out := FlatDeptInfo(depts, "dc=example,dc=com") + + if out["od-a"].Chain != "Acme.kaifazu" { + t.Errorf("od-a 链 = %q", out["od-a"].Chain) + } + if out["od-b"].Chain != "Acme.kaifazu.od-b" { + t.Errorf("od-b 链 = %q, 期望追加部门 ID 消歧", out["od-b"].Chain) + } + if out["od-a"].DN == out["od-b"].DN { + t.Errorf("两个同名部门的 DN 不应相同") + } +} + +// TestFlatDeptInfoStable 验证同一输入产生的 DN 稳定(幂等,便于物化比对)。 +func TestFlatDeptInfoStable(t *testing.T) { + depts := []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-rd", "od-root", "研发部"), + } + a := FlatDeptInfo(depts, "dc=example,dc=com") + b := FlatDeptInfo(depts, "dc=example,dc=com") + for id := range a { + if a[id].DN != b[id].DN || a[id].Chain != b[id].Chain { + t.Errorf("部门 %s 的命名不稳定: %q vs %q", id, a[id].DN, b[id].DN) + } + } +} \ No newline at end of file diff --git a/internal/mapping/mapping_test.go b/internal/mapping/mapping_test.go index db89cc9..59b9958 100644 --- a/internal/mapping/mapping_test.go +++ b/internal/mapping/mapping_test.go @@ -85,33 +85,8 @@ func TestBuildGroupOrderingIsParentFirst(t *testing.T) { } } -// 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) - } -} +// TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名, +// 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。 // TestBuildUsernameFromEnterpriseEmail 验证登录名来源。 func TestBuildUsernameFromEnterpriseEmail(t *testing.T) { diff --git a/internal/mapping/mirror.go b/internal/mapping/mirror.go new file mode 100644 index 0000000..febeb6f --- /dev/null +++ b/internal/mapping/mirror.go @@ -0,0 +1,122 @@ +package mapping + +// 镜像 → 期望 LLDAP 状态 的落库化映射。 +// +// 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型) +// 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是 +// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,因此组用拍平拼音点分命名; +// 用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是物理删除(见 syncer 阶段 6)。 + +import ( + "sort" + "strings" + + "idsync/internal/ldaplib" + "idsync/internal/store" +) + +// DesiredGroup 是期望的分组(拍平命名)。 +type DesiredGroup struct { + DeptID string + // DN 是完整组 DN(来自 mirror_departments,形式 cn=<父.子>,ou=groups,)。 + DN string + // Chain 是拍平拼音链,仅展示用。 + Chain string + Name string + // Level 是部门在树中的层级,用于保证父组先于子组创建。 + Level int + // Members 是期望成员的用户 DN 集合。 + Members map[string]struct{} +} + +// DesiredUser 是期望的用户。 +// +// 不再有 InactiveDN:离职走物理删除,用户 DN 固定 ou=people/。 +type DesiredUser struct { + UID string + DN string + CN string + SN string + GivenName string + DisplayName string + Mail string + Mobile string + EmployeeNumber string + DepartmentNumber string + PostalAddress string + BusinessCategory string + Description string + DeptIDs []string +} + +// DesiredState 是期望写入 LLDAP 的完整状态。 +type DesiredState struct { + Groups []*DesiredGroup + Users []*DesiredUser + GroupByDeptID map[string]*DesiredGroup +} + +// BuildFromMirror 由镜像的活动用户与活动部门构造期望状态。纯函数,不触碰 IO。 +// +// 组名与 DN 直接复用镜像落库时算好的 PinyinName/DN(见 FlatDeptInfo),保证 +// 物化与预览看到的是同一份命名,不会出现「写入了但预览里看不到」的分叉。 +func BuildFromMirror(users []*store.MirrorUser, depts []*store.MirrorDepartment, baseDN string) *DesiredState { + st := &DesiredState{ + Groups: make([]*DesiredGroup, 0, len(depts)), + Users: make([]*DesiredUser, 0, len(users)), + GroupByDeptID: make(map[string]*DesiredGroup, len(depts)), + } + peopleOU := "ou=people," + strings.TrimSpace(baseDN) + + for _, d := range depts { + g := &DesiredGroup{ + DeptID: d.DeptID, + DN: d.DN, + Chain: d.PinyinName, + Name: d.Name, + Level: d.Level, + Members: make(map[string]struct{}), + } + st.Groups = append(st.Groups, g) + st.GroupByDeptID[d.DeptID] = g + } + + for _, u := range users { + uid := u.UID + du := &DesiredUser{ + UID: uid, + DN: ldaplib.UserDN(uid, peopleOU), + CN: uid, + SN: u.SN, + GivenName: u.GivenName, + DisplayName: u.DisplayName, + Mail: u.Email, + Mobile: u.Mobile, + EmployeeNumber: u.EmployeeNumber, + DepartmentNumber: u.DepartmentNumber, + PostalAddress: u.PostalAddress, + BusinessCategory: u.BusinessCategory, + Description: u.Description, + DeptIDs: u.DepartmentIDs, + } + st.Users = append(st.Users, du) + + // 维护成员关系:用户只加入其实际所属且在本轮同步范围内的部门组。 + for _, deptID := range u.DepartmentIDs { + if g, ok := st.GroupByDeptID[deptID]; ok { + g.Members[du.DN] = struct{}{} + } + } + } + + // 分组按层级排序,父组必须排在子组之前(LLDAP 组虽不嵌套,但物化创建 + // 顺序仍要保持父先于子,避免依赖隐含顺序造成问题)。 + sort.SliceStable(st.Groups, func(i, j int) bool { + if st.Groups[i].Level != st.Groups[j].Level { + return st.Groups[i].Level < st.Groups[j].Level + } + return st.Groups[i].DeptID < st.Groups[j].DeptID + }) + sort.SliceStable(st.Users, func(i, j int) bool { return st.Users[i].UID < st.Users[j].UID }) + return st +} \ No newline at end of file diff --git a/internal/mapping/mirror_test.go b/internal/mapping/mirror_test.go new file mode 100644 index 0000000..f88aa9d --- /dev/null +++ b/internal/mapping/mirror_test.go @@ -0,0 +1,98 @@ +package mapping + +// BuildFromMirror(镜像 → 期望状态)的单测。 +// +// 覆盖:用户 DN 固定 ou=people、组名直接复用镜像拍平 DN、成员关系按用户所属部门 +// 归集、分组按层级父先于子排序。 + +import ( + "testing" + + "idsync/internal/store" +) + +func mirrorUser(uid, email, deptID string) *store.MirrorUser { + return &store.MirrorUser{ + UID: uid, + Email: email, + DisplayName: uid, + SN: uid, + GivenName: uid, + DepartmentIDs: []string{deptID}, + } +} + +func mirrorDept(id, name, pinyin, dn string, level int, parent string) *store.MirrorDepartment { + return &store.MirrorDepartment{ + DeptID: id, Name: name, PinyinName: pinyin, DN: dn, Level: level, ParentID: parent, + } +} + +// TestBuildFromMirrorUserDN 验证用户 DN 固定挂在 ou=people 下,不再有离职 OU。 +func TestBuildFromMirrorUserDN(t *testing.T) { + depts := []*store.MirrorDepartment{ + mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), + } + users := []*store.MirrorUser{mirrorUser("alice", "alice@example.com", "od-root")} + + st := BuildFromMirror(users, depts, "dc=example,dc=com") + if len(st.Users) != 1 { + t.Fatalf("期望 1 个用户,实际 %d", len(st.Users)) + } + u := st.Users[0] + if u.DN != "uid=alice,ou=people,dc=example,dc=com" { + t.Errorf("用户 DN = %q, 期望固定 ou=people", u.DN) + } + if u.Mail != "alice@example.com" { + t.Errorf("mail = %q", u.Mail) + } +} + +// TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。 +func TestBuildFromMirrorGroupMembers(t *testing.T) { + depts := []*store.MirrorDepartment{ + mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), + mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"), + } + users := []*store.MirrorUser{ + mirrorUser("alice", "alice@example.com", "od-rd"), + // lisi 属多部门 + {UID: "lisi", Email: "lisi@example.com", DisplayName: "lisi", SN: "lisi", + GivenName: "lisi", DepartmentIDs: []string{"od-root", "od-rd"}}, + } + + st := BuildFromMirror(users, depts, "dc=example,dc=com") + + rd := st.GroupByDeptID["od-rd"] + if _, ok := rd.Members["uid=alice,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("alice 未加入 %s", rd.DN) + } + if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("lisi 未加入 %s", rd.DN) + } + root := st.GroupByDeptID["od-root"] + if _, ok := root.Members["uid=alice,ou=people,dc=example,dc=com"]; ok { + t.Errorf("alice 只属研发部,不应出现在根部门") + } + if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("lisi 属根部门,应出现") + } +} + +// TestBuildFromMirrorGroupOrderIsParentFirst 验证分组输出按层级父先于子排序。 +func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) { + depts := []*store.MirrorDepartment{ + // 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。 + mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"), + mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), + } + + st := BuildFromMirror(nil, depts, "dc=example,dc=com") + seen := map[string]int{} + for i, g := range st.Groups { + seen[g.DeptID] = i + } + if seen["od-root"] > seen["od-rd"] { + t.Errorf("父部门应排在子部门之前:%v", seen) + } +} \ No newline at end of file