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:
+171
-450
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user