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/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
package mapping
import (
"strings"
"testing"
"time"
"idsync/internal/feishu"
)
func dept(id, parent, name string) *feishu.Department {
return &feishu.Department{ID: id, ParentID: parent, Name: name}
}
func user(uid, name, email string, depts ...string) *feishu.User {
return &feishu.User{
UnionID: "u-" + uid,
Name: name,
EnterpriseEmail: email,
DepartmentIDs: depts,
}
}
func options() BuildOptions {
return BuildOptions{
UserOUDN: "ou=people,dc=example,dc=com",
InactiveOUDN: "ou=Inactive,dc=example,dc=com",
GroupRootDN: "ou=feishuroot,dc=example,dc=com",
}
}
// TestBuildGroupOrderingIsParentFirst 是一个关键回归测试。
//
// 分组必须按层级排序,父分组排在子分组之前。早期实现按部门 ID 字母序排序,
// 当子部门的 ID 字母序恰好排在父部门之前时,创建子分组会因父分组尚不存在
// 而失败(LDAP No Such Object)。
func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// 子部门的 ID 字母序刻意排在父部门之前
dept("od-aaa-child", "od-root", "子部门"),
dept("od-root", "0", "根部门"),
dept("od-zzz-grandchild", "od-aaa-child", "孙部门"),
},
}
st := Build(snap, options())
seen := make(map[string]int)
for i, g := range st.Groups {
seen[g.DeptID] = i
}
for _, g := range st.Groups {
if g.ParentID == "" || g.ParentID == "0" {
continue
}
pi, ok := seen[g.ParentID]
if !ok {
continue // 父不在范围内
}
ci := seen[g.DeptID]
if pi > ci {
t.Errorf("子分组 %s 排在父分组 %s 之前(%d > %d)",
g.DeptID, g.ParentID, ci, pi)
}
}
// 深度应正确反映层级
for _, g := range st.Groups {
switch g.DeptID {
case "od-root":
if g.Depth != 0 {
t.Errorf("根部门深度 = %d, 期望 0", g.Depth)
}
case "od-aaa-child":
if g.Depth != 1 {
t.Errorf("子部门深度 = %d, 期望 1", g.Depth)
}
case "od-zzz-grandchild":
if g.Depth != 2 {
t.Errorf("孙部门深度 = %d, 期望 2", g.Depth)
}
}
}
}
// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。
func TestBuildGroupDNReflectsNesting(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
dept("od-root", "0", "Acme"),
dept("od-rd", "od-root", "研发部"),
dept("od-backend", "od-rd", "后端组"),
},
}
st := Build(snap, options())
rootDN := st.GroupByDeptID["od-root"].DN
if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("根部门 DN = %q", rootDN)
}
rdDN := st.GroupByDeptID["od-rd"].DN
if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN)
}
bdDN := st.GroupByDeptID["od-backend"].DN
if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("后端组 DN = %q", bdDN)
}
}
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
user("1", "张三", "ZhangSan@Example.com", "od-root"),
},
}
st := Build(snap, options())
if len(st.Users) != 1 {
t.Fatalf("用户数 = %d, 期望 1", len(st.Users))
}
u := st.Users[0]
// 登录名应小写且不含域名
if u.UID != "zhangsan" {
t.Errorf("uid = %q, 期望 zhangsan(应取企业邮箱前缀并转小写)", u.UID)
}
if u.DN != "uid=zhangsan,ou=people,dc=example,dc=com" {
t.Errorf("DN = %q", u.DN)
}
if u.InactiveDN != "uid=zhangsan,ou=Inactive,dc=example,dc=com" {
t.Errorf("离职 DN = %q", u.InactiveDN)
}
// mail 必须是企业邮箱本身(保留原始大小写)
if u.Mail != "ZhangSan@Example.com" {
t.Errorf("mail = %q, 期望企业邮箱原值", u.Mail)
}
if u.DisplayName != "张三" {
t.Errorf("displayName = %q, 期望 张三", u.DisplayName)
}
}
// TestBuildSkipsUserWithoutEnterpriseEmail 验证缺少企业邮箱的用户不会被臆造登录名。
//
// 这里刻意不断言「回落到个人邮箱」——那样的行为会产生不可预期、
// 且离职后仍然有效的登录名,因此必须跳过。
func TestBuildSkipsUserWithoutEnterpriseEmail(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
// 有个人邮箱但没有企业邮箱
{
UnionID: "u-nomail",
Name: "孙七",
Email: "sq@personal.com",
DepartmentIDs: []string{"od-root"},
},
},
}
st := Build(snap, options())
if len(st.Users) != 0 {
t.Errorf("缺少企业邮箱的用户不应被映射为登录名,实际生成 %d 个", len(st.Users))
}
}
// TestBuildMembership 验证成员关系按部门正确归集。
func TestBuildMembership(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
dept("od-root", "0", "Acme"),
dept("od-rd", "od-root", "研发部"),
},
Users: []*feishu.User{
user("1", "张三", "zhangsan@example.com", "od-rd"),
// 一人属多部门
user("2", "李四", "lisi@example.com", "od-root", "od-rd"),
},
}
st := Build(snap, options())
rd := st.GroupByDeptID["od-rd"]
if _, ok := rd.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("张三 未加入研发部")
}
if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("李四 未加入研发部")
}
root := st.GroupByDeptID["od-root"]
if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("李四 未加入根部门")
}
if _, ok := root.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; ok {
t.Errorf("张三 只属研发部,不应出现在根部门")
}
}
// TestBuildOrphanDepartmentWarnsAndAttachesToRoot 验证父部门不在范围内时的处理。
//
// 行为是「挂到分组根下并告警」,而不是静默跳过或静默挂错层级:
// 静默跳过会让部门凭空消失,静默挂错层级会让人以为结构正确。
func TestBuildOrphanDepartmentWarnsAndAttachesToRoot(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// 父部门 od-missing 不在快照中
dept("od-orphan", "od-missing", "孤儿部门"),
},
}
st := Build(snap, options())
g, ok := st.GroupByDeptID["od-orphan"]
if !ok {
t.Fatalf("父部门不在范围内的部门仍应被同步(挂到分组根下)")
}
if want := "cn=guerbumen,ou=feishuroot,dc=example,dc=com"; g.DN != want {
t.Errorf("孤儿部门 DN = %q, 期望 %q", g.DN, want)
}
found := false
for _, w := range st.Warnings {
if strings.Contains(w, "od-missing") && strings.Contains(w, "不在同步范围内") {
found = true
break
}
}
if !found {
t.Errorf("父部门不在范围内时应产生告警,实际告警: %v", st.Warnings)
}
}
// TestBuildCycleProtection 验证部门树出现环时不会死循环。
func TestBuildCycleProtection(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// a 的父是 b,b 的父是 a:构造一个环
dept("od-a", "od-b", "A"),
dept("od-b", "od-a", "B"),
},
}
done := make(chan *State, 1)
go func() { done <- Build(snap, options()) }()
select {
case st := <-done:
// 关键是能返回而不是卡死
if st == nil {
t.Fatalf("Build 返回 nil")
}
case <-timeAfterSeconds(5):
t.Fatalf("部门数据成环时 Build 未在 5 秒内返回,可能存在死循环")
}
}
// TestToPinyin 验证部门名到 CN 的转换与清理。
func TestToPinyin(t *testing.T) {
cases := []struct {
in string
want string
}{
{"研发部", "yanfabu"},
{"后端组", "houduanzu"},
{"Acme", "Acme"},
{"IT Dept", "IT_Dept"},
{"", ""},
{" ", ""},
{"Finance-2", "Finance-2"},
}
for _, c := range cases {
if got := toPinyin(c.in); got != c.want {
t.Errorf("toPinyin(%q) = %q, 期望 %q", c.in, got, c.want)
}
}
}
// TestCnNameFallsBackToDeptID 验证纯符号部门名不会产生空 CN。
func TestCnNameFallsBackToDeptID(t *testing.T) {
got := cnName("★☆", "od-xyz")
if got == "" {
t.Errorf("cnName 不应返回空字符串")
}
if !strings.Contains(got, "od-xyz") {
t.Errorf("无法转为拼音时应回落到部门 ID,实际: %q", got)
}
}
// TestAttrsPreserveEmptyOptionalFields 验证可选字段为空时不会填充臆造值。
//
// 这些属性在 LDAP 侧会走删除分支,而不是写入 "Default: xxx" 之类的占位值。
func TestBuildKeepsOptionalFieldsEmpty(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
{
UnionID: "u-1", Name: "张三", EnterpriseEmail: "zhangsan@example.com",
// Mobile/EmployeeNo/JobTitle/WorkStation 全部留空
DepartmentIDs: []string{"od-root"},
},
},
}
st := Build(snap, options())
u := st.Users[0]
if u.Mobile != "" {
t.Errorf("mobile 应为空,实际 %q", u.Mobile)
}
if u.EmployeeNumber != "" {
t.Errorf("employeeNumber 应为空,实际 %q", u.EmployeeNumber)
}
if u.DepartmentNumber != "" {
t.Errorf("departmentNumber 应为空,实际 %q", u.DepartmentNumber)
}
if u.PostalAddress != "" {
t.Errorf("postalAddress 应为空,实际 %q", u.PostalAddress)
}
// sn 是 inetOrgPerson 的必填项,必须有值
if u.SN == "" {
t.Errorf("sn 不可为空(inetOrgPerson 必填)")
}
}
// timeAfterSeconds 返回一个等待指定秒数后触发的通道,用于检验死循环。
func timeAfterSeconds(n int) <-chan struct{} {
ch := make(chan struct{})
go func() {
time.Sleep(time.Duration(n) * time.Second)
close(ch)
}()
return ch
}