package syncer import ( "strings" "testing" "idsync/internal/ldaplib" "idsync/internal/mapping" ) const ( testUserOU = "ou=people,dc=example,dc=com" testInactiveOU = "ou=Inactive,dc=example,dc=com" testGroupRoot = "ou=feishuroot,dc=example,dc=com" testAdminDN = "cn=admin,dc=example,dc=com" ) // entry 构造一个 LDAP 条目,便于测试。 func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry { return ldaplib.NewEntry(dn, attrs, members) } func newUser(uid string) *mapping.User { return &mapping.User{ UID: uid, DN: "uid=" + uid + "," + testUserOU, InactiveDN: "uid=" + uid + "," + testInactiveOU, CN: uid, SN: uid, GivenName: uid, DisplayName: uid, Mail: uid + "@example.com", Description: uid, } } func baseInput(desired *mapping.State) *Input { return &Input{ Desired: desired, Active: map[string]*ldaplib.Entry{}, Inactive: map[string]*ldaplib.Entry{}, Groups: map[string]*ldaplib.Entry{}, LedgerAccounts: map[string]string{}, LedgerGroups: map[string]string{}, AdminDN: testAdminDN, GroupRootDN: testGroupRoot, UserOUDN: testUserOU, InactiveOUDN: testInactiveOU, } } func kindsOf(plan *Plan) map[OpKind]int { out := make(map[OpKind]int) for _, op := range plan.Ops { out[op.Kind]++ } return out } func TestComputePlan(t *testing.T) { tests := []struct { name string desired *mapping.State mutate func(in *Input) wantKinds map[OpKind]int // wantDetailContains 断言某个操作详情包含特定文本 wantDetailContains string // forbidKinds 断言这些操作绝不出现(安全性回归) forbidKinds []OpKind }{ { name: "全新用户:创建", desired: &mapping.State{ Users: []*mapping.User{newUser("alice")}, GroupByDeptID: map[string]*mapping.Group{}, }, wantKinds: map[OpKind]int{OpCreateUser: 1}, }, { name: "属性一致:不产生操作", desired: &mapping.State{ Users: []*mapping.User{newUser("alice")}, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { u := newUser("alice") in.Active["alice"] = entry(u.DN, map[string]string{ ldaplib.AttrCN: u.CN, ldaplib.AttrSN: u.SN, ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrDisplayName: u.DisplayName, ldaplib.AttrMail: u.Mail, ldaplib.AttrMobile: u.Mobile, ldaplib.AttrEmployeeNumber: u.EmployeeNumber, ldaplib.AttrDepartmentNumber: u.DepartmentNumber, ldaplib.AttrPostalAddress: u.PostalAddress, ldaplib.AttrBusinessCategory: u.BusinessCategory, ldaplib.AttrDescription: u.Description, }, nil) in.LedgerAccounts["alice"] = "active" }, wantKinds: map[OpKind]int{}, }, { name: "邮箱变化:更新属性", desired: &mapping.State{ Users: []*mapping.User{ func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(), }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { u := newUser("alice") in.Active["alice"] = entry(u.DN, map[string]string{ ldaplib.AttrCN: u.CN, ldaplib.AttrDisplayName: u.DisplayName, ldaplib.AttrMail: "old@example.com", }, nil) in.LedgerAccounts["alice"] = "active" }, wantKinds: map[OpKind]int{OpUpdateUser: 1}, wantDetailContains: "alice.new@example.com", }, { name: "台账在职但飞书消失:停用", desired: &mapping.State{ Users: []*mapping.User{}, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{ ldaplib.AttrDisplayName: "Bob", }, nil) in.LedgerAccounts["bob"] = "active" }, wantKinds: map[OpKind]int{OpDeactivate: 1}, }, { name: "非台账账号缺席:不得停用(关键安全回归)", desired: &mapping.State{ Users: []*mapping.User{}, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { // LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。 in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{ ldaplib.AttrDisplayName: "Service", }, nil) }, wantKinds: map[OpKind]int{}, forbidKinds: []OpKind{OpDeactivate}, }, { name: "在 Inactive 且台账受管:恢复", desired: &mapping.State{ Users: []*mapping.User{newUser("carol")}, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{ ldaplib.AttrDisplayName: "Carol", }, nil) in.LedgerAccounts["carol"] = "inactive" }, wantKinds: map[OpKind]int{OpReactivate: 1}, }, { name: "在 Inactive 但非台账:只告警不改动", desired: &mapping.State{ Users: []*mapping.User{newUser("dave")}, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil) }, wantKinds: map[OpKind]int{}, forbidKinds: []OpKind{OpReactivate}, }, { name: "分组不存在:创建并加成员", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1}, }, { name: "成员缺席:加入分组", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( "cn=yanfa,"+testGroupRoot, map[string]string{ldaplib.AttrDescription: "研发部"}, []string{testAdminDN}) in.LedgerAccounts["alice"] = "active" }, wantKinds: map[OpKind]int{OpAddMember: 1}, }, { name: "成员多余且受管:移出分组", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( "cn=yanfa,"+testGroupRoot, map[string]string{ldaplib.AttrDescription: "研发部"}, []string{testAdminDN, "uid=alice," + testUserOU}) in.LedgerAccounts["alice"] = "active" }, wantKinds: map[OpKind]int{OpRemoveMember: 1}, }, { name: "admin 永不被移出分组(关键安全回归)", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( "cn=yanfa,"+testGroupRoot, map[string]string{ldaplib.AttrDescription: "研发部"}, []string{testAdminDN}) }, wantKinds: map[OpKind]int{}, forbidKinds: []OpKind{OpRemoveMember}, }, { // 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让 // isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑 // 才能阻止它被移出——否则该用例会失败。没有这层构造, // 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。 name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { adminUIDDN := "uid=admin," + testUserOU in.AdminDN = adminUIDDN in.LedgerAccounts["admin"] = "active" in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( "cn=yanfa,"+testGroupRoot, map[string]string{ldaplib.AttrDescription: "研发部"}, []string{adminUIDDN}) }, wantKinds: map[OpKind]int{}, forbidKinds: []OpKind{OpRemoveMember}, }, { name: "外部手工成员:不移出,仅告警(关键安全回归)", desired: &mapping.State{ Users: []*mapping.User{}, Groups: []*mapping.Group{ { DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", Members: map[string]struct{}{}, }, }, GroupByDeptID: map[string]*mapping.Group{}, }, mutate: func(in *Input) { in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( "cn=yanfa,"+testGroupRoot, map[string]string{ldaplib.AttrDescription: "研发部"}, []string{testAdminDN, "uid=external," + testUserOU}) }, wantKinds: map[OpKind]int{}, forbidKinds: []OpKind{OpRemoveMember}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { in := baseInput(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 _, expected := tt.wantKinds[kind]; !expected { t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s", kind, count, dumpPlan(plan)) } } for _, kind := range tt.forbidKinds { if got[kind] > 0 { t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan)) } } if tt.wantDetailContains != "" { found := false for _, op := range plan.Ops { if strings.Contains(op.Detail, tt.wantDetailContains) { found = true break } } if !found { t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s", tt.wantDetailContains, dumpPlan(plan)) } } }) } } // TestComputePlanNeverTouchesPassword 是一个结构性回归测试: // 无论输入如何,计划中都不能出现改写 userPassword 的操作。 // 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。 func TestComputePlanNeverTouchesPassword(t *testing.T) { u := newUser("alice") in := baseInput(&mapping.State{ Users: []*mapping.User{u}, GroupByDeptID: map[string]*mapping.Group{}, }) in.LedgerAccounts["bob"] = "active" in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil) plan := ComputePlan(in) for _, op := range plan.Ops { if strings.Contains(strings.ToLower(op.Detail), "userpassword") { t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail) } } } func TestGuard(t *testing.T) { tests := []struct { name string guard Guard plan *Plan wantErr bool }{ { name: "无停用时通过", guard: Guard{MaxDeactivatePercent: 0.2}, plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}}, }, { name: "超过绝对上限被拒", guard: Guard{MaxDeactivate: 5}, plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}}, wantErr: true, }, { name: "超过比例上限被拒", guard: Guard{MaxDeactivatePercent: 0.2}, plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}}, wantErr: true, }, { name: "比例在阈值内通过", guard: Guard{MaxDeactivatePercent: 0.2}, plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}}, }, { name: "强制模式跳过检查", guard: Guard{MaxDeactivate: 1, AllowBulk: true}, plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}}, }, } 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 dumpPlan(p *Plan) string { var b strings.Builder for _, op := range p.Ops { b.WriteString("\n ") b.WriteString(string(op.Kind)) b.WriteString(" target=") b.WriteString(op.Target) b.WriteString(" detail=") b.WriteString(op.Detail) } for _, w := range p.Warnings { b.WriteString("\n WARN ") b.WriteString(w) } return b.String() }