//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") }