//go:build integration // 集成测试:对真实的 OpenLDAP 实例验证写入路径。 // // 默认不参与 `go test ./...`,需要显式启用: // // LDAP_TEST_URL=ldap://127.0.0.1:3899 \ // go test -tags=integration ./internal/ldaplib/ -v // // 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、 // modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。 package ldaplib_test import ( "io" "log/slog" "net" "os" "strings" "testing" "time" "github.com/go-ldap/ldap/v3" "idsync/internal/config" "idsync/internal/ldaplib" "idsync/internal/password" ) // discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func testClient(t *testing.T) *ldaplib.Client { t.Helper() url := os.Getenv("LDAP_TEST_URL") if url == "" { url = "ldap://127.0.0.1:3899" } baseDN := os.Getenv("LDAP_TEST_BASE_DN") if baseDN == "" { baseDN = "dc=example,dc=com" } cfg := config.Ldap{ URL: url, BaseDN: baseDN, AdminDN: "cn=admin," + baseDN, AdminPass: "adminpass", GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"), TimeoutSec: 10, } if err := cfg.NormalizeForTest(); err != nil { t.Fatalf("配置规范化失败: %v", err) } c, err := ldaplib.NewClient(cfg, discardLogger()) if err != nil { t.Fatalf("构造客户端失败: %v", err) } if err := c.Ping(); err != nil { t.Skipf("LDAP 不可用,跳过集成测试: %v", err) } return c } func baseDN() string { if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" { return v } return "dc=example,dc=com" } // TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期, // 并验证密码在属性更新与 modrdn 移动后依然有效。 func TestIntegrationUserLifecycle(t *testing.T) { c := testClient(t) base := baseDN() peopleOU := "ou=people," + base inactiveOU := "ou=Inactive," + base conn, err := c.Dial() if err != nil { t.Fatalf("连接失败: %v", err) } defer conn.Close() if err := conn.EnsureOU(peopleOU, "people"); err != nil { t.Fatalf("创建 ou=people 失败: %v", err) } if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil { t.Fatalf("创建 ou=Inactive 失败: %v", err) } const uid = "idsync-test-user" userDN := "uid=" + uid + "," + peopleOU initialPass := "InitialPass123!" // 清理可能残留的条目,让测试可重复执行。 _ = conn.DeleteUser(userDN) _ = conn.DeleteUser("uid=" + uid + "," + inactiveOU) u := &ldaplib.User{ UID: uid, CN: uid, SN: "测试", GivenName: "Test", DisplayName: "测试用户", Mail: "test@example.com", Mobile: "13800000000", EmployeeNumber: "E001", DepartmentNumber: "工程师", PostalAddress: "北京", BusinessCategory: "研发部", EmployeeType: "Active", Description: "测试用户", } // 断言密码以哈希形式存储,而不是明文。 // slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码, // 密码会以明文形式留在目录中。 encoded, err := password.EncodeSSHA(initialPass) if err != nil { t.Fatalf("编码密码失败: %v", err) } if err := conn.AddUser(u, peopleOU, encoded); err != nil { t.Fatalf("创建用户失败: %v", err) } raw, err := conn.GetEntry(userDN, []string{"userPassword"}) if err != nil { t.Fatalf("读取 userPassword 失败: %v", err) } stored := raw.Get("userPassword") if stored == initialPass { t.Errorf("userPassword 以明文存储,存在严重安全隐患") } if !strings.HasPrefix(stored, "{SSHA}") { t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored) } // 创建后应当能读到,且属性正确。 users, err := conn.ListUsers(peopleOU) if err != nil { t.Fatalf("读取用户列表失败: %v", err) } got, ok := users[uid] if !ok { t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid) } if got.Get(ldaplib.AttrMail) != "test@example.com" { t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail)) } // 用用户凭据 bind,确认密码确实生效。 if err := bindAs(t, c, userDN, initialPass); err != nil { t.Fatalf("初始密码无法通过 bind 验证: %v", err) } // 更新属性:改邮箱与手机号。 u.Mail = "test.new@example.com" u.Mobile = "13900000000" existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs()) if err != nil { t.Fatalf("读取现状失败: %v", err) } if err := conn.UpdateUser(userDN, u, existing); err != nil { t.Fatalf("更新用户失败: %v", err) } // 关键回归:把若干可选字段置空后更新,不应失败。 // 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让 // 整个 Modify 请求失败——这是真实踩到过的 bug。 u.PostalAddress = "" u.EmployeeNumber = "" existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs()) if err := conn.UpdateUser(userDN, u, existing); err != nil { t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err) } users, err = conn.ListUsers(peopleOU) if err != nil { t.Fatalf("更新后读取失败: %v", err) } got = users[uid] if got.Get(ldaplib.AttrMail) != "test.new@example.com" { t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail)) } // 关键约束:属性更新绝不能改写密码。 if err := bindAs(t, c, userDN, initialPass); err != nil { t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err) } // 移到离职 OU:密码必须随条目保留。 if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil { t.Fatalf("移动用户失败: %v", err) } movedDN := "uid=" + uid + "," + inactiveOU if err := bindAs(t, c, movedDN, initialPass); err != nil { t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err) } inactive, err := conn.ListUsers(inactiveOU) if err != nil { t.Fatalf("读取离职 OU 失败: %v", err) } if _, ok := inactive[uid]; !ok { t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid) } // 标记离职状态 if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil { t.Fatalf("设置 employeeType 失败: %v", err) } // 移回在职 OU if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil { t.Fatalf("移回失败: %v", err) } if err := bindAs(t, c, userDN, initialPass); err != nil { t.Fatalf("移回后密码失效: %v", err) } // 清理 if err := conn.DeleteUser(userDN); err != nil { t.Fatalf("删除用户失败: %v", err) } } // TestIntegrationGroupMembership 验证分组的创建与成员增删。 func TestIntegrationGroupMembership(t *testing.T) { c := testClient(t) base := baseDN() peopleOU := "ou=people," + base groupRoot := "ou=feishuroot," + base conn, err := c.Dial() if err != nil { t.Fatalf("连接失败: %v", err) } defer conn.Close() if err := conn.EnsureOU(peopleOU, "people"); err != nil { t.Fatalf("创建 ou=people 失败: %v", err) } if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil { t.Fatalf("创建分组根 OU 失败: %v", err) } const uid = "idsync-test-member" userDN := "uid=" + uid + "," + peopleOU groupDN := "cn=yanfa," + groupRoot adminDN := "cn=admin," + base _ = conn.DeleteUser(userDN) _ = conn.DeleteGroup(groupDN) u := &ldaplib.User{ UID: uid, CN: uid, SN: "成员", DisplayName: "成员", Mail: "member@example.com", EmployeeType: "Active", } if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil { t.Fatalf("创建用户失败: %v", err) } if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil { t.Fatalf("创建分组失败: %v", err) } // 幂等性:重复创建同一个组不应报错。 if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil { t.Errorf("重复创建分组应当幂等,却返回错误: %v", err) } groups, err := conn.ListGroups(groupRoot) if err != nil { t.Fatalf("读取分组失败: %v", err) } g, ok := groups[groupDN] if !ok { t.Fatalf("未找到刚创建的分组 %s", groupDN) } // 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。 if _, has := g.Members()[adminDN]; !has { t.Errorf("分组缺少 admin 成员: %v", g.Members()) } // 加入成员,并验证幂等。 if err := conn.AddMember(groupDN, userDN); err != nil { t.Fatalf("加入成员失败: %v", err) } if err := conn.AddMember(groupDN, userDN); err != nil { t.Errorf("重复加入成员应当幂等,却返回错误: %v", err) } groups, err = conn.ListGroups(groupRoot) if err != nil { t.Fatalf("重新读取分组失败: %v", err) } if _, has := groups[groupDN].Members()[userDN]; !has { t.Errorf("成员未被加入: %v", groups[groupDN].Members()) } // 移出成员,并验证幂等。 if err := conn.RemoveMember(groupDN, userDN); err != nil { t.Fatalf("移出成员失败: %v", err) } if err := conn.RemoveMember(groupDN, userDN); err != nil { t.Errorf("重复移出成员应当幂等,却返回错误: %v", err) } // 清理 _ = conn.DeleteUser(userDN) _ = conn.DeleteGroup(groupDN) } // TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。 func TestIntegrationEscaping(t *testing.T) { c := testClient(t) base := baseDN() peopleOU := "ou=people," + base conn, err := c.Dial() if err != nil { t.Fatalf("连接失败: %v", err) } defer conn.Close() if err := conn.EnsureOU(peopleOU, "people"); err != nil { t.Fatalf("创建 ou=people 失败: %v", err) } // 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。 cases := []string{ "zhang.san", "li_si", "wang+wu", "zhao,liu", } for _, uid := range cases { t.Run(uid, func(t *testing.T) { dn := ldaplib.UserDN(uid, peopleOU) _ = conn.DeleteUser(dn) defer func() { _ = conn.DeleteUser(dn) }() u := &ldaplib.User{ UID: uid, CN: uid, SN: uid, DisplayName: uid, Mail: uid + "@example.com", EmployeeType: "Active", } if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil { t.Fatalf("创建含特殊字符的 uid 失败: %v", err) } users, err := conn.ListUsers(peopleOU) if err != nil { t.Fatalf("读取失败: %v", err) } if _, ok := users[uid]; !ok { t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid) } }) } } // bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。 func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error { t.Helper() cfgURL := os.Getenv("LDAP_TEST_URL") if cfgURL == "" { cfgURL = "ldap://127.0.0.1:3899" } conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second})) if err != nil { return err } defer conn.Close() return conn.Bind(dn, password) }