Files
caowangrenbo 0fffdd5ccb 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+集成全绿
2026-09-29 23:53:24 +08:00

345 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}
}
}