test: 集成测试改用真 LLDAP 容器;修复两处物化缺陷
- 集成测试重构:syncer e2e/materialize、ldaplib 读路径、lldapgql 全改用本地 LLDAP 容器(LDAP 3890 / GraphQL /api/graphql 17170),删旧 OpenLDAP 断言 - 修复 1:ActiveUsers 不填 DepartmentIDs(存于关系表),BuildFromMirror 拿到空成员; 新增 ActiveUsersWithDepts,物化 plan 用之 - 修复 2:deleted 用户重新可见后不回 active,无法重建;RevertToActive 纳入 deleted, 快照 reconcile 对其置回 active + 作废 token - 关键安全回归单测保留并配变异验证:未确认不删、非受管不删、admin 永不移出、 删除最后阶段、绝不碰密码。全量单测+vet+集成全绿
This commit is contained in:
@@ -1,377 +1,104 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。
|
||||
// LLDAP 读路径(LDAP)集成测试:读取对象重,验证 objectClass 命中与双成员属性读取。
|
||||
//
|
||||
// 默认不参与 `go test ./...`,需要显式启用:
|
||||
// 默认不参与 `go test ./...`,需显式启用:
|
||||
//
|
||||
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||
// go test -tags=integration ./internal/ldaplib/ -v
|
||||
//
|
||||
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
|
||||
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
|
||||
// LLDAP_TEST_URL=ldap://127.0.0.1:3890 go test -tags=integration ./internal/ldaplib/ -v
|
||||
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()
|
||||
|
||||
func testConfig() config.Ldap {
|
||||
base := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if base == "" {
|
||||
base = "dc=example,dc=com"
|
||||
}
|
||||
url := os.Getenv("LDAP_TEST_URL")
|
||||
if url == "" {
|
||||
url = "ldap://127.0.0.1:3899"
|
||||
url = "ldap://127.0.0.1:3890"
|
||||
}
|
||||
baseDN := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if baseDN == "" {
|
||||
baseDN = "dc=example,dc=com"
|
||||
return config.Ldap{
|
||||
URL: url, BaseDN: base,
|
||||
AdminDN: "uid=admin,ou=people," + base, AdminPass: "adminpass",
|
||||
TimeoutSec: 10,
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
func dialT(t *testing.T) *ldaplib.Conn {
|
||||
t.Helper()
|
||||
c, err := ldaplib.NewClient(testConfig(), 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)
|
||||
t.Skipf("LLDAP 不可用,跳过集成测试: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestIntegrationReadUsers 验证用户读视图:LLDAP 默认 admin 用户应能被 ListUsers 读到。
|
||||
func TestIntegrationReadUsers(t *testing.T) {
|
||||
conn := dialT(t)
|
||||
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)
|
||||
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("编码密码失败: %v", err)
|
||||
t.Fatalf("ListUsers 失败: %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)
|
||||
if _, ok := users["admin"]; !ok {
|
||||
t.Errorf("LLDAP 默认的 admin 用户应在 ou=people 读到;现有: %v", keysOf(users))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// TestIntegrationReadGroups 验证组读视图:能列出 ou=groups 下的 LLDAP 默认组。
|
||||
func TestIntegrationReadGroups(t *testing.T) {
|
||||
conn := dialT(t)
|
||||
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)
|
||||
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("读取分组失败: %v", err)
|
||||
t.Fatalf("ListGroups 失败: %v", err)
|
||||
}
|
||||
g, ok := groups[groupDN]
|
||||
if !ok {
|
||||
t.Fatalf("未找到刚创建的分组 %s", groupDN)
|
||||
if len(groups) == 0 {
|
||||
t.Errorf("ou=groups 下应至少有 LLDAP 默认组")
|
||||
}
|
||||
// 创建分组时应自动包含 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)
|
||||
}
|
||||
})
|
||||
// 结构上应能解析成员集合(即便为空),确认 member/uniqueMember 双读不崩。
|
||||
for dn := range groups {
|
||||
_ = groups[dn].Members()
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
// TestIntegrationDNHelpers 验证 DN 构造与过滤器转义(供读侧比对复用)。
|
||||
func TestIntegrationDNHelpers(t *testing.T) {
|
||||
if got := ldaplib.UserDN("alice", "ou=people,dc=example,dc=com"); got != "uid=alice,ou=people,dc=example,dc=com" {
|
||||
t.Errorf("UserDN = %q", got)
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
|
||||
if err != nil {
|
||||
return err
|
||||
if got := ldaplib.GroupDN("od-1", "ou=groups,dc=example,dc=com"); got != "cn=od-1,ou=groups,dc=example,dc=com" {
|
||||
t.Errorf("GroupDN = %q", got)
|
||||
}
|
||||
if ldaplib.EscapeFilter("a(b)") == "a(b)" {
|
||||
t.Errorf("EscapeFilter 未转义")
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.Bind(dn, password)
|
||||
}
|
||||
|
||||
func keysOf[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user