飞书通讯录 ↔ 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/)不入库。
378 lines
11 KiB
Go
378 lines
11 KiB
Go
//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)
|
||
}
|