package syncer import ( "strings" "testing" "idsync/internal/ldaplib" "idsync/internal/mapping" ) const ( peopleOU = "ou=people,dc=example,dc=com" groupRoot = "ou=groups,dc=example,dc=com" testAdmin = "uid=admin," + peopleOU ) func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry { return ldaplib.NewEntry(dn, attrs, members) } func dUser(uid string) *mapping.DesiredUser { return &mapping.DesiredUser{ UID: uid, DN: "uid=" + uid + "," + peopleOU, Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid, } } func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup { ms := make(map[string]struct{}, len(members)) for _, m := range members { ms[m] = struct{}{} } return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms} } func depName(deptID string) string { return "部门-" + deptID } func baseIn(desired *mapping.DesiredState) *Input { return &Input{ Desired: desired, ActualUsers: map[string]*ldaplib.Entry{}, ActualGroups: map[string]*ldaplib.Entry{}, OwnedUsers: map[string]struct{}{}, ConfirmedLeave: map[string]bool{}, AdminDN: testAdmin, PeopleOU: peopleOU, GroupRootDN: groupRoot, } } func kindsOf(plan *Plan) map[OpKind]int { out := make(map[OpKind]int) for _, op := range plan.Ops { out[op.Kind]++ } return out } func plainDesired() *mapping.DesiredState { return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}} } func TestComputePlan(t *testing.T) { rdDN := "cn=od-rd," + groupRoot tests := []struct { name string desired *mapping.DesiredState mutate func(in *Input) wantKinds map[OpKind]int forbid []OpKind wantWarn string }{ { name: "全新用户:创建", desired: withUsers(plainDesired(), dUser("alice")), wantKinds: map[OpKind]int{OpCreateUser: 1}, }, { name: "属性一致:不产生操作", desired: withUsers(plainDesired(), dUser("alice")), mutate: func(in *Input) { u := dUser("alice") in.ActualUsers["alice"] = entry(u.DN, map[string]string{ ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName, ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN, }, nil) in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{}, }, { name: "邮箱变化:更新属性", desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()), mutate: func(in *Input) { in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{ ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice", ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice", }, nil) in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{OpUpdateUser: 1}, }, { name: "在 LLDAP、台账、已确认离职,但不在期望:删除", desired: plainDesired(), mutate: func(in *Input) { in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{ ldaplib.AttrDisplayName: "Bob"}, nil) in.OwnedUsers["bob"] = struct{}{} in.ConfirmedLeave["bob"] = true }, wantKinds: map[OpKind]int{OpDeleteUser: 1}, }, { name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)", desired: plainDesired(), mutate: func(in *Input) { in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil) in.OwnedUsers["bob"] = struct{}{} }, wantKinds: map[OpKind]int{}, forbid: []OpKind{OpDeleteUser}, wantWarn: "未完成离职确认", }, { name: "在 LLDAP、非受管(服务账号):绝不删除", desired: plainDesired(), mutate: func(in *Input) { in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil) in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管 }, wantKinds: map[OpKind]int{}, forbid: []OpKind{OpDeleteUser}, }, { name: "分组不存在:创建", desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), wantKinds: map[OpKind]int{OpCreateGroup: 1}, }, { name: "孤儿组(actual 有、期望没有):不删只告警", desired: plainDesired(), mutate: func(in *Input) { in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, map[string]string{ldaplib.AttrDescription: "x"}, nil) }, wantKinds: map[OpKind]int{}, forbid: []OpKind{OpCreateGroup}, wantWarn: "未自动删除", }, { name: "成员缺席:加入分组", desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), mutate: func(in *Input) { in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil) in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{OpAddMember: 1}, }, { name: "成员多余且受管:移出分组", desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=alice," + peopleOU}) in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{OpRemoveMember: 1}, }, { name: "admin 永不被移出分组(关键安全回归)", desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin}) in.OwnedUsers["admin"] = struct{}{} }, wantKinds: map[OpKind]int{}, forbid: []OpKind{OpRemoveMember}, }, { name: "外部非受管成员:不移出,仅告警", desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=external," + peopleOU}) }, wantKinds: map[OpKind]int{}, forbid: []OpKind{OpRemoveMember}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { in := baseIn(tt.desired) if tt.mutate != nil { tt.mutate(in) } plan := ComputePlan(in) got := kindsOf(plan) for kind, want := range tt.wantKinds { if got[kind] != want { t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan)) } } for kind, count := range got { if _, exp := tt.wantKinds[kind]; !exp { t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan)) } } for _, kind := range tt.forbid { if got[kind] > 0 { t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan)) } } if tt.wantWarn != "" { found := false for _, w := range plan.Warnings { if strings.Contains(w, tt.wantWarn) { found = true break } } if !found { t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings) } } }) } } // TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。 func TestDeleteIsLastPhase(t *testing.T) { rdDN := "cn=od-rd," + groupRoot desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice")) in := baseIn(desired) in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{ ldaplib.AttrDisplayName: "alice"}, nil) in.OwnedUsers["alice"] = struct{}{} in.OwnedUsers["bob"] = struct{}{} in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil) in.ConfirmedLeave["bob"] = true in.OwnedUsers["carol"] = struct{}{} in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil) in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU}) plan := ComputePlan(in) // bob:删除;alice 创建;carol 移出分组。 // 任何删除操作都必须排在任何创建/加/移之后。 maxOther, minDelete := -1, 1<<30 for i, op := range plan.Ops { if op.Kind == OpDeleteUser { if i < minDelete { minDelete = i } } else { if i > maxOther { maxOther = i } } } if minDelete <= maxOther { t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan)) } } // TestComputePlanNeverTouchesPassword 是结构性回归: // 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。 func TestComputePlanNeverTouchesPassword(t *testing.T) { in := baseIn(withUsers(plainDesired(), dUser("alice"))) plan := ComputePlan(in) for _, op := range plan.Ops { if strings.Contains(strings.ToLower(op.Detail), "userpassword") || strings.Contains(strings.ToLower(op.Detail), "password") { t.Errorf("计划出现涉及密码的操作: %s", op.Detail) } } } func TestGuard(t *testing.T) { tests := []struct { name string guard Guard plan *Plan wantErr bool }{ {"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false}, {"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true}, {"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true}, {"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false}, {"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.guard.Check(tt.plan) if tt.wantErr && err == nil { t.Errorf("期望报错,实际通过") } if !tt.wantErr && err != nil { t.Errorf("期望通过,实际报错: %v", err) } }) } } func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState { s.Users = append(s.Users, us...) return s } func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState { s.Groups = append(s.Groups, gs...) if s.GroupByDeptID == nil { s.GroupByDeptID = map[string]*mapping.DesiredGroup{} } for _, g := range gs { s.GroupByDeptID[g.DeptID] = g } return s } func dumpPlan(p *Plan) string { var b strings.Builder for _, op := range p.Ops { b.WriteString("\n [") b.WriteString(string(op.Kind)) b.WriteString("] ") b.WriteString(op.Target) b.WriteString(": ") b.WriteString(op.Detail) } for _, w := range p.Warnings { b.WriteString("\n WARN ") b.WriteString(w) } return b.String() }