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:
@@ -0,0 +1,354 @@
|
||||
// Package mapping 把飞书快照转换为期望的 LDAP 状态。
|
||||
//
|
||||
// 这一层是纯函数:不触碰 IO,不依赖配置以外的全局状态。DN 的构造规则
|
||||
// 集中在此,便于测试与审查——DN 出错会造成最难排查的故障。
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mozillazg/go-pinyin"
|
||||
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
)
|
||||
|
||||
// 员工在职状态标记。
|
||||
//
|
||||
// 定义在 mapping 而非 syncer:它是「期望状态」的一部分,需要参与差异比对,
|
||||
// 因此必须与期望状态放在一起,否则会出现「写入了但预览里看不到」的不一致。
|
||||
const (
|
||||
EmployeeTypeActive = "Active"
|
||||
EmployeeTypeResigned = "Resigned"
|
||||
)
|
||||
|
||||
// State 是期望写入 LDAP 的完整状态。
|
||||
type State struct {
|
||||
Groups []*Group
|
||||
Users []*User
|
||||
// GroupByDeptID 便于按部门查组
|
||||
GroupByDeptID map[string]*Group
|
||||
// Warnings 是映射过程中发现的问题,需要人工留意。
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Group 是期望的分组。
|
||||
type Group struct {
|
||||
DeptID string
|
||||
DN string
|
||||
Name string
|
||||
ParentID string
|
||||
// Depth 是部门在树中的层级,根为 0。用于保证父分组先于子分组创建。
|
||||
Depth int
|
||||
// Members 是期望成员的用户 uid 集合
|
||||
Members map[string]struct{}
|
||||
}
|
||||
|
||||
// User 是期望的用户。
|
||||
type User struct {
|
||||
UID string
|
||||
DN string
|
||||
InactiveDN string
|
||||
CN string
|
||||
SN string
|
||||
GivenName string
|
||||
DisplayName string
|
||||
Mail string
|
||||
Mobile string
|
||||
EmployeeNumber string
|
||||
DepartmentNumber string
|
||||
PostalAddress string
|
||||
BusinessCategory string
|
||||
Description string
|
||||
// EmployeeType 为 Active;离职条目由执行器单独标记为 Resigned。
|
||||
EmployeeType string
|
||||
DeptIDs []string
|
||||
}
|
||||
|
||||
// BuildOptions 是构造期望状态所需的配置。
|
||||
type BuildOptions struct {
|
||||
UserOUDN string
|
||||
InactiveOUDN string
|
||||
// GroupRootName 是部门树根 OU 的名称(不含 ou=)
|
||||
GroupRootName string
|
||||
GroupRootDN string
|
||||
// CollisionSuffix 在登录名冲突时用于消歧,默认取员工工号或 union_id 片段
|
||||
Disambiguate func(u *feishu.User) string
|
||||
}
|
||||
|
||||
// Build 由飞书快照构造期望状态。
|
||||
func Build(snap *feishu.Snapshot, opt BuildOptions) *State {
|
||||
st := &State{
|
||||
GroupByDeptID: make(map[string]*Group),
|
||||
Warnings: make([]string, 0),
|
||||
}
|
||||
|
||||
// 部门 → 分组的 DN 需要自顶向下构造,先建索引再按层级解析。
|
||||
deptByID := make(map[string]*feishu.Department, len(snap.Departments))
|
||||
for _, d := range snap.Departments {
|
||||
deptByID[d.ID] = d
|
||||
}
|
||||
|
||||
// 按层级排序,确保父部门先于子部门处理,从而能复用父的 DN。
|
||||
ordered := orderByDepth(snap.Departments, deptByID)
|
||||
|
||||
for _, d := range ordered {
|
||||
parentDN := opt.GroupRootDN
|
||||
if d.ParentID != "" && d.ParentID != "0" {
|
||||
switch pg, ok := st.GroupByDeptID[d.ParentID]; {
|
||||
case ok:
|
||||
parentDN = pg.DN
|
||||
case deptByID[d.ParentID] != nil:
|
||||
// 父部门在范围内但尚未处理:正常情况下不会发生,
|
||||
// 因为 ordered 已保证父先于子。出现即说明建树有缺陷。
|
||||
st.Warnings = append(st.Warnings,
|
||||
fmt.Sprintf("部门 %s(%s) 的父部门 %s 尚未处理,已跳过该部门",
|
||||
d.Name, d.ID, d.ParentID))
|
||||
continue
|
||||
default:
|
||||
// 父部门完全不在本次同步范围内(例如 dept-list 配置不当,
|
||||
// 或通讯录权限被收窄)。此时挂到分组根下,让它至少能被同步,
|
||||
// 但必须显式告警——静默地把部门挂到错误层级比失败更难察觉。
|
||||
st.Warnings = append(st.Warnings,
|
||||
fmt.Sprintf("部门 %s(%s) 的父部门 %s 不在同步范围内,已挂到 %s 下,请确认层级是否正确",
|
||||
d.Name, d.ID, d.ParentID, opt.GroupRootDN))
|
||||
}
|
||||
}
|
||||
|
||||
name := cnName(d.Name, d.ID)
|
||||
g := &Group{
|
||||
DeptID: d.ID,
|
||||
DN: ldaplib.GroupDN(name, parentDN),
|
||||
Name: d.Name,
|
||||
ParentID: d.ParentID,
|
||||
Depth: depthOf(d.ID, deptByID),
|
||||
Members: make(map[string]struct{}),
|
||||
}
|
||||
st.Groups = append(st.Groups, g)
|
||||
st.GroupByDeptID[d.ID] = g
|
||||
}
|
||||
|
||||
// 用户
|
||||
for _, u := range snap.Users {
|
||||
uid, ok := feishu.LoginName(u)
|
||||
if !ok {
|
||||
continue // 已在快照的 SkippedNoEmail 中报告
|
||||
}
|
||||
|
||||
du := &User{
|
||||
UID: uid,
|
||||
DN: ldaplib.UserDN(uid, opt.UserOUDN),
|
||||
InactiveDN: ldaplib.UserDN(uid, opt.InactiveOUDN),
|
||||
CN: uid,
|
||||
SN: firstName(u),
|
||||
GivenName: firstNonEmpty(u.EnName, u.Name, uid),
|
||||
DisplayName: firstNonEmpty(u.Name, u.EnName, uid),
|
||||
Mail: u.EnterpriseEmail,
|
||||
Mobile: u.Mobile,
|
||||
EmployeeNumber: u.EmployeeNo,
|
||||
DepartmentNumber: u.JobTitle,
|
||||
PostalAddress: u.WorkStation,
|
||||
BusinessCategory: strings.Join(deptNames(u.DepartmentIDs, deptByID), ","),
|
||||
Description: firstNonEmpty(u.Name, u.EnName, uid),
|
||||
EmployeeType: EmployeeTypeActive,
|
||||
DeptIDs: u.DepartmentIDs,
|
||||
}
|
||||
st.Users = append(st.Users, du)
|
||||
|
||||
// 维护成员关系:只把用户加入其实际所属且存在于本次同步范围的部门组。
|
||||
for _, deptID := range u.DepartmentIDs {
|
||||
if g, ok := st.GroupByDeptID[deptID]; ok {
|
||||
g.Members[du.DN] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关键:按层级排序,父分组必须排在子分组之前。
|
||||
//
|
||||
// 不能用 DeptID 字母序:部门 ID 与层级无关,字母序会把子部门排到父部门
|
||||
// 前面,导致创建子分组时父分组尚不存在(LDAP 报 No Such Object)。
|
||||
// 这个问题在部门名恰好有序时才不会暴露,因此必须显式按深度排序。
|
||||
sort.SliceStable(st.Groups, func(i, j int) bool {
|
||||
if st.Groups[i].Depth != st.Groups[j].Depth {
|
||||
return st.Groups[i].Depth < st.Groups[j].Depth
|
||||
}
|
||||
return st.Groups[i].DeptID < st.Groups[j].DeptID
|
||||
})
|
||||
sort.SliceStable(st.Users, func(i, j int) bool {
|
||||
return st.Users[i].UID < st.Users[j].UID
|
||||
})
|
||||
return st
|
||||
}
|
||||
|
||||
// depthOf 计算部门在树中的深度,根部门为 0。
|
||||
//
|
||||
// 带有步数上限,防止数据异常(如父子互指形成环)时无限循环。
|
||||
func depthOf(id string, byID map[string]*feishu.Department) int {
|
||||
const maxDepth = 64
|
||||
|
||||
depth := 0
|
||||
cur := id
|
||||
for i := 0; i < maxDepth; i++ {
|
||||
d, ok := byID[cur]
|
||||
if !ok || d.ParentID == "" || d.ParentID == "0" {
|
||||
return depth
|
||||
}
|
||||
if _, ok := byID[d.ParentID]; !ok {
|
||||
return depth // 父不在同步范围内,视作根
|
||||
}
|
||||
depth++
|
||||
cur = d.ParentID
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// cnName 为部门生成稳定的 CN。
|
||||
//
|
||||
// 用拼音而非中文:中文作 RDN 在 DN 中需要转义为 #hex 形式,可读性差且
|
||||
// 在部分工具链下有兼容问题;拼音与现有目录的部门命名也保持一致。
|
||||
// 拼音为空(部门名为纯符号)时回落到部门 ID,保证 CN 非空且稳定。
|
||||
func cnName(deptName, deptID string) string {
|
||||
name := toPinyin(deptName)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return "dept-" + deptID
|
||||
}
|
||||
|
||||
// toPinyin 把中文转为无音调拼音,保留 ASCII 与数字。
|
||||
func toPinyin(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 已全是 ASCII 时直接使用,避免拼音库对英文做多余处理。
|
||||
if isASCII(s) {
|
||||
return sanitizeCN(s)
|
||||
}
|
||||
|
||||
args := pinyin.NewArgs()
|
||||
args.Style = pinyin.Normal
|
||||
parts := pinyin.Pinyin(s, args)
|
||||
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
if len(p) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(p[0])
|
||||
}
|
||||
return sanitizeCN(b.String())
|
||||
}
|
||||
|
||||
// sanitizeCN 清理 CN 中会导致 DN 书写困难的字符。
|
||||
func sanitizeCN(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '.':
|
||||
// 空格与点号在 CN 中会造成歧义,转为下划线
|
||||
b.WriteRune('_')
|
||||
case r > 127:
|
||||
// 剩余的非 ASCII(拼音库未能转换的字符)丢弃
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// orderByDepth 返回按层级(父在子前)排序的部门列表。
|
||||
//
|
||||
// 使用迭代式的层级展开,并对异常数据(环、悬挂父引用)做保护:
|
||||
// 无法归入任何层级的部门会被追加到末尾,绝不死循环。
|
||||
func orderByDepth(all []*feishu.Department, byID map[string]*feishu.Department) []*feishu.Department {
|
||||
children := make(map[string][]*feishu.Department)
|
||||
roots := make([]*feishu.Department, 0)
|
||||
for _, d := range all {
|
||||
if d.ParentID == "" || d.ParentID == "0" {
|
||||
roots = append(roots, d)
|
||||
continue
|
||||
}
|
||||
if _, ok := byID[d.ParentID]; !ok {
|
||||
roots = append(roots, d) // 父不在范围内,当根处理
|
||||
continue
|
||||
}
|
||||
children[d.ParentID] = append(children[d.ParentID], d)
|
||||
}
|
||||
|
||||
out := make([]*feishu.Department, 0, len(all))
|
||||
visited := make(map[string]bool, len(all))
|
||||
|
||||
var walk func(list []*feishu.Department)
|
||||
walk = func(list []*feishu.Department) {
|
||||
for _, d := range list {
|
||||
if visited[d.ID] {
|
||||
continue // 环保护
|
||||
}
|
||||
visited[d.ID] = true
|
||||
out = append(out, d)
|
||||
walk(children[d.ID])
|
||||
}
|
||||
}
|
||||
walk(roots)
|
||||
|
||||
// 未被访问到的(处在环中)按 ID 顺序补齐,避免静默丢部门。
|
||||
var rest []*feishu.Department
|
||||
for _, d := range all {
|
||||
if !visited[d.ID] {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID })
|
||||
out = append(out, rest...)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func deptNames(ids []string, byID map[string]*feishu.Department) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if d, ok := byID[id]; ok && d.Name != "" {
|
||||
out = append(out, d.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// firstName 为 inetOrgPerson 的必填属性 sn 提供值。
|
||||
// 拆不出姓氏时用全名,保证非空。
|
||||
func firstName(u *feishu.User) string {
|
||||
if n := strings.TrimSpace(u.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if n := strings.TrimSpace(u.EnName); n != "" {
|
||||
return n
|
||||
}
|
||||
return u.UnionID
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user