diff --git a/internal/ldaplib/integration_test.go b/internal/ldaplib/integration_test.go index 836da14..e94dabf 100644 --- a/internal/ldaplib/integration_test.go +++ b/internal/ldaplib/integration_test.go @@ -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 +} \ No newline at end of file diff --git a/internal/store/mirror.go b/internal/store/mirror.go index 36cde12..c15ab9f 100644 --- a/internal/store/mirror.go +++ b/internal/store/mirror.go @@ -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 diff --git a/internal/store/mirror_test.go b/internal/store/mirror_test.go index 9a9e133..7f4e653 100644 --- a/internal/store/mirror_test.go +++ b/internal/store/mirror_test.go @@ -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{ diff --git a/internal/syncer/e2e_test.go b/internal/syncer/e2e_test.go index d3cc59b..1c5e601 100644 --- a/internal/syncer/e2e_test.go +++ b/internal/syncer/e2e_test.go @@ -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 - deptIDs []string - resigned bool + 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 + 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": 0, "msg": "ok", - "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" + 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 := cfg.NormalizeForTest(); err != nil { + t.Fatalf("配置规范化失败: %v", err) } - ldapCfg := config.Ldap{ - URL: ldapURL, BaseDN: base, - AdminDN: "cn=admin," + base, AdminPass: "adminpass", - GroupObjectClass: groupOC, - TimeoutSec: 10, - } - if err := ldapCfg.NormalizeForTest(); err != nil { - t.Fatalf("LDAP 配置规范化失败: %v", err) + 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(ldapCfg, log) + 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() - - const base = "dc=example,dc=com" - peopleOU := "ou=people," + base - groupRoot := "ou=feishuroot," + base - - conn, err := ldapClient.Dial() - 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) +func markResigned2(f *fakeFeishu, unionID string) { + for i := range f.users { + if f.users[i].unionID == unionID { + f.users[i].resigned = true } } - - 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"}, - }) - } - - 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) { +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 { + return nil, err + } + return c.Dial() +} + +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 +} + +// 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 - } - for uid := range users { - _ = conn.DeleteUser("uid=" + uid + "," + ou) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, uid := range []string{"alice", "bob", "admin"} { + _ = gql.DeleteUser(ctx, uid) + } + 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() -} +} \ No newline at end of file diff --git a/internal/syncer/materialize_integration_test.go b/internal/syncer/materialize_integration_test.go new file mode 100644 index 0000000..1e0bb33 --- /dev/null +++ b/internal/syncer/materialize_integration_test.go @@ -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") +} \ No newline at end of file diff --git a/internal/syncer/runner.go b/internal/syncer/runner.go index 1bddf02..4a9803e 100644 --- a/internal/syncer/runner.go +++ b/internal/syncer/runner.go @@ -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 } diff --git a/internal/syncer/snapshot.go b/internal/syncer/snapshot.go index 19cfa84..a618ecc 100644 --- a/internal/syncer/snapshot.go +++ b/internal/syncer/snapshot.go @@ -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 }