Files
idsync/internal/syncer/e2e_test.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

625 lines
19 KiB
Go
Raw 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
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
//
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
// 并断言最终落在 OpenLDAP 里的内容。
//
// 启用方式(需要真实 LDAP 实例):
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
package syncer_test
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/store"
"idsync/internal/syncer"
)
// fakeDept 是假服务返回的部门。
type fakeDept struct {
openID string
parentID string
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
}
// 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,
})
})
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 {
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, "msg": "ok",
"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) {
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, "msg": "ok",
"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
}
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
}
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 {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *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"
}
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)
}
ldapClient, err := ldaplib.NewClient(ldapCfg, 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())
feishuClient, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake",
BaseURL: ts.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)
if err != nil {
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)
cleanup := func() {
ts.Close()
st.Close()
}
return runner, ldapClient, st, fake, cleanup
}
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
// 属性变更、部门调整、离职迁移与重新入职。
func TestEndToEndSync(t *testing.T) {
runner, ldapClient, st, fake, cleanup := setup(t)
defer cleanup()
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + 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"},
}
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)
}
// 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))
}
// ---- 第二步:真正执行 ----
_, 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)
}
users, err = conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if _, ok := users["zhangsan"]; !ok {
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
}
if _, ok := users["lisi"]; !ok {
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
}
if _, ok := users["wangwu"]; ok {
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
}
// 断言属性映射: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)
}
// 断言分组与成员关系。
groups, err := conn.ListGroups(groupRoot)
if 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))
}
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)
}
users, _ = conn.ListUsers(peopleOU)
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
t.Errorf("职位未更新: %q", got)
}
// ---- 第五步:离职应迁入 ou=Inactive ----
fake.users[1].resigned = true
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("离职检测失败: %v", err)
}
if plan.Stats.Deactivate != 1 {
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行离职迁移失败: %v", err)
}
users, _ = conn.ListUsers(peopleOU)
if _, still := users["lisi"]; still {
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
}
inactive, _ := conn.ListUsers(inactiveOU)
if _, moved := inactive["lisi"]; !moved {
t.Errorf("lisi 未迁入 %s", inactiveOU)
}
// 离职用户也应从部门分组中移除。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
}
// ---- 第六步:重新入职应迁回 ----
fake.users[1].resigned = false
plan, _, err = runner.Run(t.Context(), true, false, "test")
if 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)
}
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)
}
}
// 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)
}
}
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) {
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)
}
}
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()
}