Files
idsync/internal/ldaplib/integration_test.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

378 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}