//go:build integration // 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。 // // 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、 // LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。 // 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通, // 并断言最终落在 OpenLDAP 里的内容。 // // 启用方式(需要真实 LDAP 实例): // // LDAP_TEST_URL=ldap://127.0.0.1:3899 \ // go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd package syncer_test import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "sort" "strings" "testing" "time" "idsync/internal/config" "idsync/internal/feishu" "idsync/internal/ldaplib" "idsync/internal/store" "idsync/internal/syncer" ) // fakeDept 是假服务返回的部门。 type fakeDept struct { openID string parentID string 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 } // 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, }) }) 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 { for changed := true; changed; { changed = false for _, d := range f.depts { if !want[d.openID] && want[d.parentID] { want[d.openID] = true changed = true } } } } 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, }) } writeFakeJSON(w, map[string]any{ "code": 0, "msg": "ok", "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) { 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, }) } writeFakeJSON(w, map[string]any{ "code": 0, "msg": "ok", "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, "status": map[string]any{"is_resigned": u.resigned}, }, }, }) return } writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"}) }) return mux } 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 { for _, v := range list { if v == want { return true } } return false } // setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。 func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *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, TimeoutSec: 10, } if err := ldapCfg.NormalizeForTest(); err != nil { t.Fatalf("LDAP 配置规范化失败: %v", err) } ldapClient, err := ldaplib.NewClient(ldapCfg, 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()) feishuClient, err := feishu.New(feishu.Options{ AppID: "fake", AppSecret: "fake", BaseURL: ts.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) if err != nil { 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) cleanup := func() { ts.Close() st.Close() } return runner, ldapClient, st, fake, cleanup } // TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、 // 属性变更、部门调整、离职迁移与重新入职。 func TestEndToEndSync(t *testing.T) { runner, ldapClient, st, fake, cleanup := setup(t) defer cleanup() const base = "dc=example,dc=com" peopleOU := "ou=people," + base inactiveOU := "ou=Inactive," + base groupRoot := "ou=feishuroot," + 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"}, } 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) } // 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)) } // ---- 第二步:真正执行 ---- _, 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) } users, err = conn.ListUsers(peopleOU) if err != nil { t.Fatalf("读取用户失败: %v", err) } if _, ok := users["zhangsan"]; !ok { t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users)) } if _, ok := users["lisi"]; !ok { t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users)) } if _, ok := users["wangwu"]; ok { t.Errorf("wangwu 缺少企业邮箱,不应被创建") } // 断言属性映射: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) } // 断言分组与成员关系。 groups, err := conn.ListGroups(groupRoot) if 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)) } 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) } users, _ = conn.ListUsers(peopleOU) if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" { t.Errorf("职位未更新: %q", got) } // ---- 第五步:离职应迁入 ou=Inactive ---- fake.users[1].resigned = true plan, _, err = runner.Run(t.Context(), true, false, "test") if err != nil { t.Fatalf("离职检测失败: %v", err) } if plan.Stats.Deactivate != 1 { t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan)) } if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil { t.Fatalf("执行离职迁移失败: %v", err) } users, _ = conn.ListUsers(peopleOU) if _, still := users["lisi"]; still { t.Errorf("lisi 已离职,不应仍在 %s", peopleOU) } inactive, _ := conn.ListUsers(inactiveOU) if _, moved := inactive["lisi"]; !moved { t.Errorf("lisi 未迁入 %s", inactiveOU) } // 离职用户也应从部门分组中移除。 groups, _ = conn.ListGroups(groupRoot) if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has { t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN) } // ---- 第六步:重新入职应迁回 ---- fake.users[1].resigned = false plan, _, err = runner.Run(t.Context(), true, false, "test") if 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) } 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) } } // 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) } } 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) { 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) } } 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() }