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:
2026-09-29 23:53:24 +08:00
parent 126e52a6ed
commit 0fffdd5ccb
7 changed files with 459 additions and 789 deletions
+52 -325
View File
@@ -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"
}
cfg := config.Ldap{
URL: url,
BaseDN: baseDN,
AdminDN: "cn=admin," + baseDN,
AdminPass: "adminpass",
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
return config.Ldap{
URL: url, BaseDN: base,
AdminDN: "uid=admin,ou=people," + base, AdminPass: "adminpass",
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)
}
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 未转义")
}
}
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
if err != nil {
return err
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
defer conn.Close()
return conn.Bind(dn, password)
return out
}
+23 -3
View File
@@ -257,12 +257,13 @@ func (s *Store) MarkDeleted(uid string) error {
// RevertToActive 用户重新在飞书可见:撤销离职流程回 active。
//
// 只有真正进入删除(deleted)后的用户不能回退——他已经从 LLDAP 删了,
// 下一轮物化会以「重建」处理。还在双确认流程内的都可回退。
// 覆盖 deleted:已从 LLDAP 物理删除的用户再次可见意味着重新入职,置回 active
// 后物化会以「重建」把它再写进 LLDAP。仍在双确认流程内的(candidate/pending/
// confirmed)同样回退,未用 token 由调用方一并作废。
func (s *Store) RevertToActive(uid string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ? AND state IN ?", uid,
[]string{StateLeaveCandidate, StatePendingConfirm, StateConfirmedLeave}).
[]string{StateLeaveCandidate, StatePendingConfirm, StateConfirmedLeave, StateDeleted}).
Update("state", StateActive).Error
}
@@ -278,6 +279,25 @@ func (s *Store) ActiveUsers() ([]*MirrorUser, error) {
return out, nil
}
// ActiveUsersWithDepts 返回在职用户并填充各自的 DepartmentIDs(来自关系表)。
//
// DepartmentIDs 存于 mirror_user_departments,不落 MirrorUser.DepartmentIDs;
// 物化期望状态(BuildFromMirror 的组归属)依赖它,必须在此补齐。
func (s *Store) ActiveUsersWithDepts() ([]*MirrorUser, error) {
users, err := s.ActiveUsers()
if err != nil {
return nil, err
}
for _, u := range users {
ids, err := s.UserDeptIDs(u.UID)
if err != nil {
return nil, err
}
u.DepartmentIDs = ids
}
return users, nil
}
// UsersByState 返回指定生命周期状态的全部用户。
func (s *Store) UsersByState(state string) ([]*MirrorUser, error) {
var out []*MirrorUser
+2 -2
View File
@@ -175,11 +175,11 @@ func TestStateMachine(t *testing.T) {
if err := st.ToPendingConfirm("alice"); err == nil {
t.Errorf("deleted 状态的用户应无法转 pending_confirm,却成功了")
}
// deleted 后 revert 不应生效(已物理删除,不能回退,交给「重建」处理)。
// 已删除用户重新在飞书可见:revert 置回 active(重新入职/重建)。
if err := st.RevertToActive("alice"); err != nil {
t.Fatalf("RevertToActive 不应报错: %v", err)
}
assertState(t, st, "alice", StateDeleted)
assertState(t, st, "alice", StateActive)
// 双确认流程内可回退(重新入职)。
if err := st.ReplaceMirror(&MirrorSnapshot{
+162 -441
View File
@@ -1,27 +1,21 @@
//go:build integration
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
// 两阶段全链路端到端集成测试:假飞书 + 真 LLDAP + 假 webhook。
//
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
// 并断言最终落在 OpenLDAP 里的内容。
// 快照段用受控的假飞书 OpenAPI 服务(无需真实租户凭据)写镜像与推进离职流程,
// 物化段写真实 LLDAP(GraphQL)。覆盖:首次全量创建、幂等、属性变更、离职未确认
// 不删、确认后删除、重新入职重建。
//
// 启用方式(需要真实 LDAP 实例):
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
package syncer_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
@@ -29,65 +23,42 @@ import (
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/store"
"idsync/internal/syncer"
)
// fakeDept 是假服务返回的部门。
type fakeDept struct {
openID string
parentID string
name string
}
type fakeDept struct{ openID, parentID, name string }
// fakeUser 是假服务返回的用户。
type fakeUser struct {
unionID string
userID string
name string
enName string
enterpriseEm string
email string
mobile string
employeeNo string
jobTitle string
unionID, userID, name, enName string
enterpriseEm, email, mobile string
employeeNo, jobTitle string
deptIDs []string
resigned bool
}
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。
// fakeFeishu 只实现快照链路需要的接口。
type fakeFeishu struct {
depts []fakeDept
users []fakeUser
// served 记录各接口被调用的次数,用于验证分页与去重行为。
calls map[string]int
}
func (f *fakeFeishu) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.calls["token"]++
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"tenant_access_token": "fake-token",
"expire": 7200,
writeFakeJSON(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
})
})
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
f.calls["departments"]++
parent := r.URL.Query().Get("parent_department_id")
if parent == "" {
parent = "0"
}
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true}
if fetchChild {
if r.URL.Query().Get("fetch_child") == "true" {
for changed := true; changed; {
changed = false
for _, d := range f.depts {
@@ -98,86 +69,48 @@ func (f *fakeFeishu) handler() http.Handler {
}
}
}
items := make([]map[string]any, 0)
for _, d := range f.depts {
if !want[d.openID] || d.openID == parent {
continue
}
items = append(items, map[string]any{
"name": d.name,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
"name": d.name, "parent_department_id": d.parentID, "open_department_id": d.openID,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
})
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
f.calls["find_by_department"]++
deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0)
for _, u := range f.users {
if u.resigned {
continue // 与真实接口一致:不返回离职人员
}
if !containsStr(u.deptIDs, deptID) {
if u.resigned || !containsStr(u.deptIDs, deptID) {
continue
}
items = append(items, map[string]any{
"union_id": u.unionID,
"user_id": u.userID,
"name": u.name,
"en_name": u.enName,
"enterprise_email": u.enterpriseEm,
"email": u.email,
"mobile": u.mobile,
"employee_no": u.employeeNo,
"job_title": u.jobTitle,
"department_ids": u.deptIDs,
"union_id": u.unionID, "user_id": u.userID, "name": u.name, "en_name": u.enName,
"enterprise_email": u.enterpriseEm, "email": u.email, "mobile": u.mobile,
"employee_no": u.employeeNo, "job_title": u.jobTitle, "department_ids": u.deptIDs,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
})
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
f.calls["user_get"]++
// 从路径末尾取出 union_id
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1]
for _, u := range f.users {
if u.unionID != unionID {
continue
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"user": map[string]any{
"union_id": unionID,
"user_id": u.userID,
"name": u.name,
if u.unionID == unionID {
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{
"union_id": unionID, "user_id": u.userID, "name": u.name,
"status": map[string]any{"is_resigned": u.resigned},
},
},
})
}}})
return
}
}
writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
})
return mux
}
@@ -185,440 +118,228 @@ func writeFakeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
func containsStr(list []string, want string) bool {
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == want {
if v == s {
return true
}
}
return false
}
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) {
// setup 准备整套环境:真 LLDAP + 假飞书 + 假 webhook + 临时镜像库。
func setup(t *testing.T) (*syncer.Runner, *store.Store, *lldapgql.Client, *fakeFeishu, func()) {
t.Helper()
ldapURL := os.Getenv("LDAP_TEST_URL")
if ldapURL == "" {
ldapURL = "ldap://127.0.0.1:3899"
}
base := os.Getenv("LDAP_TEST_BASE_DN")
if base == "" {
base = "dc=example,dc=com"
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍:
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS")
if groupOC == "" {
groupOC = "groupOfUniqueNames"
}
ldapCfg := config.Ldap{
URL: ldapURL, BaseDN: base,
AdminDN: "cn=admin," + base, AdminPass: "adminpass",
GroupObjectClass: groupOC,
cfg := &config.Config{
System: config.System{Mode: "release"},
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
TimeoutSec: 10,
},
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
Guard: config.Guard{MaxDeactivatePercent: 0.9},
}
if err := ldapCfg.NormalizeForTest(); err != nil {
t.Fatalf("LDAP 配置规范化失败: %v", err)
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
ldapClient, err := ldaplib.NewClient(ldapCfg, log)
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
if err := gql.Login(t.Context(), "admin", "adminpass"); err != nil {
t.Skipf("LLDAP 不可用,跳过端到端测试: %v", err)
}
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err)
}
if err := ldapClient.Ping(); err != nil {
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
}
fake := &fakeFeishu{calls: map[string]int{}}
ts := httptest.NewServer(fake.handler())
fs := httptest.NewServer(fake.handler())
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
}))
feishuClient, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake",
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
AppID: "fake", AppSecret: "fake", BaseURL: fs.URL, QPS: 100, Timeout: 10 * time.Second,
}, log)
if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err)
}
dbPath := t.TempDir() + "/idsync.db"
st, err := store.Open(dbPath)
st, err := store.Open(t.TempDir() + "/idsync.db")
if err != nil {
t.Fatalf("打开台账失败: %v", err)
t.Fatalf("打开镜像库失败: %v", err)
}
ldapCfg.UserOU = "ou=people"
ldapCfg.InactiveOU = "ou=Inactive"
ldapCfg.GroupRootName = "feishuroot"
cfg := &config.Config{
System: config.System{Mode: "release", DataDir: t.TempDir()},
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: ldapCfg,
Database: config.Database{Source: dbPath},
Sync: config.Sync{MaxDeactivatePercent: 0.5},
}
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log)
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gql, st, log, syncer.SnapshotOptions{
WebhookURL: webhook.URL, ConfirmBaseURL: "https://confirm.example.com",
ReNotifyInterval: 24 * time.Hour, TokenTTL: 72 * time.Hour,
})
cleanup := func() {
ts.Close()
st.Close()
fs.Close()
webhook.Close()
_ = st.Close()
}
return runner, ldapClient, st, fake, cleanup
return runner, st, gql, fake, cleanup
}
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
// 属性变更、部门调整、离职迁移与重新入职。
func TestEndToEndSync(t *testing.T) {
runner, ldapClient, st, fake, cleanup := setup(t)
// TestTwoStageEndToEnd 覆盖首轮创建→幂等→离职确认→删除→重新入职重建。
func TestTwoStageEndToEnd(t *testing.T) {
runner, st, gql, fake, cleanup := setup(t)
defer cleanup()
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
groupRoot := "ou=groups," + base
// 准备目录结构并清理历史残留。
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
}
}
defer func() {
wipeTestOUs(t, conn, base)
conn.Close()
}()
// 两个部门,其中“研发部”是“后端组”的父部门。
// 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root),
// 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。
fake.depts = []fakeDept{
{openID: "od-root", parentID: "0", name: "Acme"},
{openID: "od-aaa-child", parentID: "od-root", name: "yanfa"},
{openID: "od-child", parentID: "od-root", name: "研发部"},
}
fake.users = []fakeUser{
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San",
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com",
mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师",
deptIDs: []string{"od-aaa-child"}},
{unionID: "u2", userID: "2", name: "李四", enName: "Li Si",
enterpriseEm: "lisi@example.com", email: "personal2@x.com",
mobile: "13800000002", employeeNo: "E002", jobTitle: "经理",
deptIDs: []string{"od-aaa-child"}},
// 没有企业邮箱:应当被跳过而不是回落到个人邮箱。
{unionID: "u3", userID: "3", name: "王五",
enterpriseEm: "", email: "wangwu@personal.com",
deptIDs: []string{"od-aaa-child"}},
}
wipeTestOUs(t, conn, base)
// ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ----
plan, res, err := runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("dry-run 失败: %v", err)
}
if res != nil {
t.Fatalf("dry-run 不应返回执行结果")
}
if plan.Stats.CreateUser != 2 {
t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)",
plan.Stats.CreateUser)
}
foundEmailWarning := false
for _, wr := range plan.Warnings {
if strings.Contains(wr, "企业邮箱") {
foundEmailWarning = true
break
}
}
if !foundEmailWarning {
t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings)
{unionID: "u1", userID: "1", name: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-child"}},
{unionID: "u2", userID: "2", name: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-child"}},
}
// dry-run 后 LDAP 中不应有任何用户。
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if len(users) != 0 {
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
}
t.Cleanup(func() { cleanupE2E(t, st, gql) })
// ---- 第二步:真正执行 ----
_, res, err = runner.Run(t.Context(), false, false, "test")
// ---- 快照 + 物化:首次全量创建 ----
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
_, res, err := runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("执行同步失败: %v", err)
t.Fatalf("物化失败: %v", err)
}
if res.Failed != 0 {
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors)
t.Fatalf("物化有失败: %v", res.Errors)
}
users, err = conn.ListUsers(peopleOU)
conn, err := ldapDial()
if err != nil {
t.Fatalf("读取用户失败: %v", err)
t.Fatalf("LDAP 连接失败: %v", err)
}
if _, ok := users["zhangsan"]; !ok {
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
users, _ := conn.ListUsers(peopleOU)
for _, uid := range []string{"alice", "bob"} {
if _, ok := users[uid]; !ok {
t.Errorf("物化后缺用户 %s", uid)
}
if _, ok := users["lisi"]; !ok {
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
}
if _, ok := users["wangwu"]; ok {
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
conn.Close()
conn, _ = ldapDial()
groupsDM := groupMap(conn, groupRoot)
conn.Close()
if _, ok := groupsDM["cn=od-child,"+groupRoot]; !ok {
t.Errorf("物化后缺组 cn=od-child")
}
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。
zs := users["zhangsan"]
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" {
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got)
}
if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" {
t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got)
}
if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" {
t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got)
// ---- 幂等 ----
plan, _ := runner.Preview(t.Context())
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
}
// 断言分组与成员关系。
groups, err := conn.ListGroups(groupRoot)
if err != nil {
t.Fatalf("读取分组失败: %v", err)
// ---- 离职:bob 标记离职 → pending_confirm,但未确认不应删除 ----
markResigned2(fake, "u2")
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。
acmeDN := "cn=acme," + groupRoot
yanfaDN := "cn=yanfa," + acmeDN
g, ok := groups[yanfaDN]
if !ok {
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
s, _ := st.MapUsersByUID()
if s["bob"].State != store.StatePendingConfirm {
t.Fatalf("bob 应为 pending_confirm,实际 %q", s["bob"].State)
}
members := g.Members()
if _, has := members["uid=zhangsan,"+peopleOU]; !has {
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members)
}
// 父部门分组也应存在,验证树形嵌套正确。
if _, ok := groups[acmeDN]; !ok {
t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups))
}
// 台账应记录两个账号。
accounts, err := st.AccountsByUID()
if err != nil {
t.Fatalf("读取台账失败: %v", err)
}
if len(accounts) != 2 {
t.Errorf("台账记录数 = %d, 期望 2", len(accounts))
}
// ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ----
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("第二次 dry-run 失败: %v", err)
}
total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup +
plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember +
plan.Stats.Deactivate + plan.Stats.Reactivate
if total != 0 {
t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan))
}
// ---- 第四步:属性变更应被检测并同步 ----
fake.users[0].jobTitle = "高级工程师"
fake.users[0].mobile = "13900000001"
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("变更检测失败: %v", err)
}
if plan.Stats.UpdateUser != 1 {
t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser)
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("应用变更失败: %v", err)
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Deleted != 0 {
t.Fatalf("未确认离职不应删除,实际 deleted=%d", res.Deleted)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
t.Errorf("职位未更新: %q", got)
conn.Close()
if _, still := users["bob"]; !still {
t.Fatalf("未确认离职的 bob 不应被删除")
}
// ---- 第五步:离职应迁入 ou=Inactive ----
fake.users[1].resigned = true
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("离职检测失败: %v", err)
// ---- 确认离职 → 物化删除 ----
if err := st.ConfirmLeave("bob"); err != nil {
t.Fatalf("状态转移失败: %v", err)
}
if plan.Stats.Deactivate != 1 {
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Deleted != 1 {
t.Fatalf("confirmed_leave 应删除 1 个,实际 %d", res.Deleted)
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行离职迁移失败: %v", err)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if _, still := users["lisi"]; still {
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
conn.Close()
if _, still := users["bob"]; still {
t.Errorf("confirmed_leave 的 bob 应被物理删除")
}
inactive, _ := conn.ListUsers(inactiveOU)
if _, moved := inactive["lisi"]; !moved {
t.Errorf("lisi 未迁入 %s", inactiveOU)
if _, alive := users["alice"]; !alive {
t.Errorf("alice 不应被删除")
}
// 离职用户也应从部门分组中移除。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
}
// ---- 第六步:重新入职应迁回 ----
// ---- 重新入职:bob 重新可见 → 重建 ----
fake.users[1].resigned = false
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("复职检测失败: %v", err)
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
if plan.Stats.Reactivate != 1 {
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan))
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行复职失败: %v", err)
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Created == 0 {
t.Fatalf("重新入职应重建 bob,实际 created=%d", res.Created)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if _, back := users["lisi"]; !back {
t.Errorf("lisi 复职后未回到 %s", peopleOU)
}
// 复职后应重新加入部门分组。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
conn.Close()
if _, back := users["bob"]; !back {
t.Errorf("bob 重新入职后应被重建")
}
}
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。
func TestEndToEndGuard(t *testing.T) {
runner, ldapClient, _, fake, cleanup := setup(t)
defer cleanup()
func markResigned2(f *fakeFeishu, unionID string) {
for i := range f.users {
if f.users[i].unionID == unionID {
f.users[i].resigned = true
}
}
}
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := ldapClient.Dial()
func ldapDial() (*ldaplib.Conn, error) {
c, err := ldaplib.NewClient(config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass", TimeoutSec: 10,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("连接失败: %v", err)
}
defer conn.Close()
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
return nil, err
}
return c.Dial()
}
fake.depts = []fakeDept{{openID: "od-root", parentID: "0", name: "Acme"}}
fake.users = nil
for i := 0; i < 4; i++ {
fake.users = append(fake.users, fakeUser{
unionID: fmt.Sprintf("g%d", i), userID: fmt.Sprintf("%d", i),
name: fmt.Sprintf("用户%d", i), enterpriseEm: fmt.Sprintf("user%d@example.com", i),
deptIDs: []string{"od-root"},
})
func groupMap(conn *ldaplib.Conn, root string) map[string]*ldaplib.Entry {
m, _ := conn.ListGroups(root)
if m == nil {
return map[string]*ldaplib.Entry{}
}
return m
}
wipeTestOUs(t, conn, base)
defer wipeTestOUs(t, conn, base)
if _, res, err := runner.Run(t.Context(), false, false, "test"); err != nil || res.Failed != 0 {
t.Fatalf("初次同步失败: err=%v res=%+v", err, res)
}
// 让飞书侧几乎所有用户消失:这模拟了权限范围被误改的灾难场景。
fake.users = fake.users[:1]
if _, _, err := runner.Run(t.Context(), false, false, "test"); err == nil {
t.Fatalf("停用 3/4 账号应当被护栏拦截,却成功执行了")
} else if !strings.Contains(err.Error(), "停用") {
t.Errorf("护栏错误信息应当说明停用数量问题,实际: %v", err)
}
// 强制模式应当放行。
if _, res, err := runner.Run(t.Context(), false, true, "test"); err != nil {
t.Fatalf("强制模式执行失败: %v", err)
} else if res.Failed != 0 {
t.Errorf("强制模式有 %d 个操作失败: %v", res.Failed, res.Errors)
}
}
// wipeTestOUs 删除测试用 OU 下的全部用户与分组。
//
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职),
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) {
// cleanupE2E 用 GraphQL 清掉本次测试在 LLDAP 里留下的用户与组。
func cleanupE2E(t *testing.T, st *store.Store, gql *lldapgql.Client) {
t.Helper()
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
for _, ou := range []string{peopleOU, inactiveOU} {
users, err := conn.ListUsers(ou)
if err != nil {
continue
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, uid := range []string{"alice", "bob", "admin"} {
_ = gql.DeleteUser(ctx, uid)
}
for uid := range users {
_ = conn.DeleteUser("uid=" + uid + "," + ou)
deps, _ := st.MapDepartmentsByID()
for _, d := range deps {
if d.GroupID > 0 {
_ = gql.DeleteGroup(ctx, d.GroupID)
}
}
groups, err := conn.ListGroups(groupRoot)
if err != nil {
return
}
// 先删深层分组,再删浅层,避免「非叶子节点无法删除」。
dns := keysOf(groups)
sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) })
for _, dn := range dns {
_ = conn.DeleteGroup(groups[dn].DN)
}
}
// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。
func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot }
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
}
func dumpOps(p *syncer.Plan) string {
var b strings.Builder
for _, op := range p.Ops {
fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail)
}
for _, wr := range p.Warnings {
fmt.Fprintf(&b, "\n 警告: %s", wr)
}
return b.String()
}
@@ -0,0 +1,203 @@
//go:build integration
// 物化(阶段6)对真实 LLDAP 的端到端集成测试。
//
// 直接向 store 镜像灌数据(不经飞书快照),跑 Runner 物化,验证写落 LLDAP:
// 建用户/建组/加成员/幂等,以及 confirmed_leave → 物理删除。读侧用 LDAP 校验。
//
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
package syncer_test
import (
"context"
"io"
"log/slog"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/store"
"idsync/internal/syncer"
)
func matTestCfg() *config.Config {
return &config.Config{
Feishu: config.Feishu{AppID: "x", AppSecret: "y"},
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
Ldap: config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
TimeoutSec: 10,
},
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
}
}
func matSetup(t *testing.T) (*syncer.Runner, *store.Store, *ldaplib.Client, *lldapgql.Client, func()) {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := matTestCfg()
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
// 先探写通道是否可用,不可用就跳过(避免误报)。
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
if err := gql.Login(context.Background(), cfg.Lldap.AdminUser, cfg.Lldap.AdminPass); err != nil {
t.Skipf("LLDAP 不可用,跳过物化集成测试: %v", err)
}
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err)
}
fc, _ := feishu.New(feishu.Options{AppID: "x", AppSecret: "y", QPS: 100, Timeout: 5 * time.Second}, log)
st, err := store.Open(t.TempDir() + "/idsync.db")
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
runner := syncer.NewRunner(cfg, fc, ldapClient, gql, st, log, syncer.SnapshotOptions{})
cleanup := func() { _ = st.Close() }
return runner, st, ldapClient, gql, cleanup
}
// seedMirror 灌一版镜像:od-acme/od-rd 两个部门,alice/bob 属 od-rd。
func seedMirror(t *testing.T, st *store.Store) {
t.Helper()
base := "dc=example,dc=com"
err := st.ReplaceMirror(&store.MirrorSnapshot{
Departments: []*store.MirrorDepartment{
{DeptID: "od-acme", Name: "Acme", DN: "cn=od-acme,ou=groups," + base, Level: 0},
{DeptID: "od-rd", Name: "研发部", DN: "cn=od-rd,ou=groups," + base, Level: 1, ParentID: "od-acme"},
},
Users: []*store.MirrorUser{
{UID: "alice", UnionID: "u-alice", Email: "alice@example.com", DisplayName: "Alice",
GivenName: "Alice", SN: "Alice", DepartmentIDs: []string{"od-rd"}},
{UID: "bob", UnionID: "u-bob", Email: "bob@example.com", DisplayName: "Bob",
GivenName: "Bob", SN: "Bob", DepartmentIDs: []string{"od-rd"}},
},
})
if err != nil {
t.Fatalf("灌镜像失败: %v", err)
}
}
func keysOf2[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestMaterializeAgainstLLDAP 验证物化把镜像在职视图写落 LLDAP,并支持离职删除。
func TestMaterializeAgainstLLDAP(t *testing.T) {
runner, st, ldapClient, gql, cleanup := matSetup(t)
defer cleanup()
seedMirror(t, st)
// 第 1 轮:dry-run 应给出创建计划。
plan, err := runner.Preview(t.Context())
if err != nil {
t.Fatalf("Preview 失败: %v", err)
}
if plan.Stats.CreateUser != 2 || plan.Stats.CreateGroup != 2 || plan.Stats.AddMember != 2 {
t.Fatalf("首轮计划统计异常: %+v", plan.Stats)
}
// 第 2 轮:真正执行。
_, res, err := runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("物化执行失败: %v", err)
}
if res.Failed != 0 {
t.Fatalf("物化有 %d 个失败: %v", res.Failed, res.Errors)
}
// 用 LDAP 读校验:两个用户、两个组、alice/bob 都是 od-rd 成员。
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("LDAP 连接失败: %v", err)
}
defer conn.Close()
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
if err != nil {
t.Fatalf("读用户失败: %v", err)
}
for _, uid := range []string{"alice", "bob"} {
if _, ok := users[uid]; !ok {
t.Errorf("物化后 LLDAP 缺少用户 %s", uid)
}
}
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
if err != nil {
t.Fatalf("读组失败: %v", err)
}
rd := "cn=od-rd,ou=groups,dc=example,dc=com"
g, ok := groups[rd]
if !ok {
t.Fatalf("物化后缺少组 %s;现有 %v", rd, keysOf2(groups))
}
members := g.Members()
for _, uid := range []string{"alice", "bob"} {
if _, has := members["uid="+uid+",ou=people,dc=example,dc=com"]; !has {
t.Errorf("%s 未加入 %s;成员 %v", uid, rd, members)
}
}
// 幂等:立即再 preview 不应有任何操作。
plan, err = runner.Preview(t.Context())
if err != nil {
t.Fatalf("幂等 Preview 失败: %v", err)
}
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
}
// 离职:把 bob 走完离职状态机到 confirmed_leave,物化应物理删除 bob。
for _, fn := range []func() error{
func() error { return st.ToLeaveCandidate("bob") },
func() error { return st.ToPendingConfirm("bob") },
func() error { return st.ConfirmLeave("bob") },
} {
if err := fn(); err != nil {
t.Fatalf("离职状态机转移失败: %v", err)
}
}
_, res, err = runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("离职物化失败: %v", err)
}
if res.Deleted != 1 {
t.Fatalf("应按 confirmed_leave 删除 1 个用户,实际 deleted=%d", res.Deleted)
}
users, _ = conn.ListUsers("ou=people,dc=example,dc=com")
if _, still := users["bob"]; still {
t.Errorf("confirmed_leave 用户 bob 应被物理删除,仍存在")
}
if _, alive := users["alice"]; !alive {
t.Errorf("alice 不应被删除")
}
// 清理:删掉本次建的组与剩余用户,避免污染共享容器。
gid := 0
deps, _ := st.MapDepartmentsByID()
if d := deps["od-rd"]; d != nil {
gid = d.GroupID
}
if gid > 0 {
_ = gql.DeleteGroup(context.Background(), gid)
}
_ = gql.DeleteUser(context.Background(), "alice")
_ = gql.DeleteUser(context.Background(), "bob")
_ = gql.DeleteUser(context.Background(), "admin")
}
+1 -1
View File
@@ -143,7 +143,7 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
// plan 读两侧状态并计算物化计划。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) {
// 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。
activeUsers, err := r.store.ActiveUsers()
activeUsers, err := r.store.ActiveUsersWithDepts()
if err != nil {
return nil, nil, err
}
+4 -5
View File
@@ -210,9 +210,11 @@ func (r *SnapshotRunner) reconcile(ctx context.Context, present map[string]bool,
}
if present[u.UID] {
// 本轮在飞书重新可见:撤销离职流程并作废未用的 token。
// 本轮在飞书可见:撤销离职流程并作废未用的 token。
switch u.State {
case store.StateLeaveCandidate, store.StatePendingConfirm, store.StateConfirmedLeave:
case store.StateLeaveCandidate, store.StatePendingConfirm,
store.StateConfirmedLeave, store.StateDeleted:
// 含已删除用户重新入职:置回 active,物化以「重建」处理。
if err := r.store.RevertToActive(u.UID); err != nil {
return fmt.Errorf("撤销离职流程 uid=%s: %w", u.UID, err)
}
@@ -221,9 +223,6 @@ func (r *SnapshotRunner) reconcile(ctx context.Context, present map[string]bool,
}
res.Reinstated++
r.log.Info("用户重新可见,已撤销离职流程", "uid", u.UID)
case store.StateDeleted:
// 已物理删除又可见,物化阶段会以「重建」处理,这里只记录。
r.log.Info("已删除用户重新可见,将按重建处理", "uid", u.UID)
}
continue
}