//go:build integration // 两阶段全链路端到端集成测试:假飞书 + 真 LLDAP + 假 webhook。 // // 快照段用受控的假飞书 OpenAPI 服务(无需真实租户凭据)写镜像与推进离职流程, // 物化段写真实 LLDAP(GraphQL)。覆盖:首次全量创建、幂等、属性变更、离职未确认 // 不删、确认后删除、重新入职重建。 // // 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。 package syncer_test import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "idsync/internal/config" "idsync/internal/feishu" "idsync/internal/ldaplib" "idsync/internal/lldapgql" "idsync/internal/store" "idsync/internal/syncer" ) type fakeDept struct{ openID, parentID, name string } type fakeUser struct { unionID, userID, name, enName string enterpriseEm, email, mobile string employeeNo, jobTitle string deptIDs []string resigned bool } // fakeFeishu 只实现快照链路需要的接口。 type fakeFeishu struct { depts []fakeDept users []fakeUser 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, "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" } want := map[string]bool{parent: true} if r.URL.Query().Get("fetch_child") == "true" { 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, "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 || !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, "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"]++ parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/") unionID := parts[len(parts)-1] for _, u := range f.users { 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 } func writeFakeJSON(w http.ResponseWriter, body any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(body) } func containsStr(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } // setup 准备整套环境:真 LLDAP + 假飞书 + 假 webhook + 临时镜像库。 func setup(t *testing.T) (*syncer.Runner, *store.Store, *lldapgql.Client, *fakeFeishu, func()) { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) 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) } 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) } fake := &fakeFeishu{calls: map[string]int{}} 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: fs.URL, QPS: 100, Timeout: 10 * time.Second, }, log) if err != nil { t.Fatalf("构造飞书客户端失败: %v", err) } st, err := store.Open(t.TempDir() + "/idsync.db") if err != nil { t.Fatalf("打开镜像库失败: %v", err) } 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() { fs.Close() webhook.Close() _ = st.Close() } return runner, st, gql, fake, cleanup } // 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 groupRoot := "ou=groups," + base fake.depts = []fakeDept{ {openID: "od-root", parentID: "0", name: "Acme"}, {openID: "od-child", parentID: "od-root", name: "研发部"}, } fake.users = []fakeUser{ {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"}}, } t.Cleanup(func() { cleanupE2E(t, st, gql) }) // ---- 快照 + 物化:首次全量创建 ---- 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) } if res.Failed != 0 { t.Fatalf("物化有失败: %v", res.Errors) } conn, err := ldapDial() if err != nil { t.Fatalf("LDAP 连接失败: %v", err) } users, _ := conn.ListUsers(peopleOU) for _, uid := range []string{"alice", "bob"} { if _, ok := users[uid]; !ok { t.Errorf("物化后缺用户 %s", uid) } } conn.Close() conn, _ = ldapDial() groupsDM := groupMap(conn, groupRoot) conn.Close() if _, ok := groupsDM["cn=od-child,"+groupRoot]; !ok { t.Errorf("物化后缺组 cn=od-child") } // ---- 幂等 ---- 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) } // ---- 离职:bob 标记离职 → pending_confirm,但未确认不应删除 ---- markResigned2(fake, "u2") if _, err := runner.RunSnapshot(t.Context()); err != nil { t.Fatalf("快照失败: %v", err) } s, _ := st.MapUsersByUID() if s["bob"].State != store.StatePendingConfirm { t.Fatalf("bob 应为 pending_confirm,实际 %q", s["bob"].State) } _, res, _ = runner.Run(t.Context(), false, false, "test") if res.Deleted != 0 { t.Fatalf("未确认离职不应删除,实际 deleted=%d", res.Deleted) } conn, _ = ldapDial() users, _ = conn.ListUsers(peopleOU) conn.Close() if _, still := users["bob"]; !still { t.Fatalf("未确认离职的 bob 不应被删除") } // ---- 确认离职 → 物化删除 ---- if err := st.ConfirmLeave("bob"); err != nil { t.Fatalf("状态转移失败: %v", err) } _, res, _ = runner.Run(t.Context(), false, false, "test") if res.Deleted != 1 { t.Fatalf("confirmed_leave 应删除 1 个,实际 %d", res.Deleted) } conn, _ = ldapDial() users, _ = conn.ListUsers(peopleOU) conn.Close() if _, still := users["bob"]; still { t.Errorf("confirmed_leave 的 bob 应被物理删除") } if _, alive := users["alice"]; !alive { t.Errorf("alice 不应被删除") } // ---- 重新入职:bob 重新可见 → 重建 ---- fake.users[1].resigned = false if _, err := runner.RunSnapshot(t.Context()); 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) conn.Close() if _, back := users["bob"]; !back { t.Errorf("bob 重新入职后应被重建") } } func markResigned2(f *fakeFeishu, unionID string) { for i := range f.users { if f.users[i].unionID == unionID { f.users[i].resigned = true } } } 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() 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) } } }