- 集成测试重构: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+集成全绿
345 lines
11 KiB
Go
345 lines
11 KiB
Go
//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)
|
||
}
|
||
}
|
||
} |