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