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
+435
View File
@@ -0,0 +1,435 @@
package syncer
import (
"strings"
"testing"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
)
const (
testUserOU = "ou=people,dc=example,dc=com"
testInactiveOU = "ou=Inactive,dc=example,dc=com"
testGroupRoot = "ou=feishuroot,dc=example,dc=com"
testAdminDN = "cn=admin,dc=example,dc=com"
)
// entry 构造一个 LDAP 条目,便于测试。
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
return ldaplib.NewEntry(dn, attrs, members)
}
func newUser(uid string) *mapping.User {
return &mapping.User{
UID: uid,
DN: "uid=" + uid + "," + testUserOU,
InactiveDN: "uid=" + uid + "," + testInactiveOU,
CN: uid,
SN: uid,
GivenName: uid,
DisplayName: uid,
Mail: uid + "@example.com",
Description: uid,
}
}
func baseInput(desired *mapping.State) *Input {
return &Input{
Desired: desired,
Active: map[string]*ldaplib.Entry{},
Inactive: map[string]*ldaplib.Entry{},
Groups: map[string]*ldaplib.Entry{},
LedgerAccounts: map[string]string{},
LedgerGroups: map[string]string{},
AdminDN: testAdminDN,
GroupRootDN: testGroupRoot,
UserOUDN: testUserOU,
InactiveOUDN: testInactiveOU,
}
}
func kindsOf(plan *Plan) map[OpKind]int {
out := make(map[OpKind]int)
for _, op := range plan.Ops {
out[op.Kind]++
}
return out
}
func TestComputePlan(t *testing.T) {
tests := []struct {
name string
desired *mapping.State
mutate func(in *Input)
wantKinds map[OpKind]int
// wantDetailContains 断言某个操作详情包含特定文本
wantDetailContains string
// forbidKinds 断言这些操作绝不出现(安全性回归)
forbidKinds []OpKind
}{
{
name: "全新用户:创建",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateUser: 1},
},
{
name: "属性一致:不产生操作",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrSN: u.SN,
ldaplib.AttrGivenName: u.GivenName,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: u.Mail,
ldaplib.AttrMobile: u.Mobile,
ldaplib.AttrEmployeeNumber: u.EmployeeNumber,
ldaplib.AttrDepartmentNumber: u.DepartmentNumber,
ldaplib.AttrPostalAddress: u.PostalAddress,
ldaplib.AttrBusinessCategory: u.BusinessCategory,
ldaplib.AttrDescription: u.Description,
}, nil)
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{},
},
{
name: "邮箱变化:更新属性",
desired: &mapping.State{
Users: []*mapping.User{
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: "old@example.com",
}, nil)
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpUpdateUser: 1},
wantDetailContains: "alice.new@example.com",
},
{
name: "台账在职但飞书消失:停用",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Bob",
}, nil)
in.LedgerAccounts["bob"] = "active"
},
wantKinds: map[OpKind]int{OpDeactivate: 1},
},
{
name: "非台账账号缺席:不得停用(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Service",
}, nil)
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpDeactivate},
},
{
name: "在 Inactive 且台账受管:恢复",
desired: &mapping.State{
Users: []*mapping.User{newUser("carol")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
ldaplib.AttrDisplayName: "Carol",
}, nil)
in.LedgerAccounts["carol"] = "inactive"
},
wantKinds: map[OpKind]int{OpReactivate: 1},
},
{
name: "在 Inactive 但非台账:只告警不改动",
desired: &mapping.State{
Users: []*mapping.User{newUser("dave")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpReactivate},
},
{
name: "分组不存在:创建并加成员",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
},
{
name: "成员缺席:加入分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpAddMember: 1},
},
{
name: "成员多余且受管:移出分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=alice," + testUserOU})
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpRemoveMember: 1},
},
{
name: "admin 永不被移出分组(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
{
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
// 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。
name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
adminUIDDN := "uid=admin," + testUserOU
in.AdminDN = adminUIDDN
in.LedgerAccounts["admin"] = "active"
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{adminUIDDN})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
{
name: "外部手工成员:不移出,仅告警(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=external," + testUserOU})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := baseInput(tt.desired)
if tt.mutate != nil {
tt.mutate(in)
}
plan := ComputePlan(in)
got := kindsOf(plan)
for kind, want := range tt.wantKinds {
if got[kind] != want {
t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s",
kind, got[kind], want, dumpPlan(plan))
}
}
for kind, count := range got {
if _, expected := tt.wantKinds[kind]; !expected {
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s",
kind, count, dumpPlan(plan))
}
}
for _, kind := range tt.forbidKinds {
if got[kind] > 0 {
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
}
}
if tt.wantDetailContains != "" {
found := false
for _, op := range plan.Ops {
if strings.Contains(op.Detail, tt.wantDetailContains) {
found = true
break
}
}
if !found {
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
tt.wantDetailContains, dumpPlan(plan))
}
}
})
}
}
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试:
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。
func TestComputePlanNeverTouchesPassword(t *testing.T) {
u := newUser("alice")
in := baseInput(&mapping.State{
Users: []*mapping.User{u},
GroupByDeptID: map[string]*mapping.Group{},
})
in.LedgerAccounts["bob"] = "active"
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil)
plan := ComputePlan(in)
for _, op := range plan.Ops {
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
}
}
}
func TestGuard(t *testing.T) {
tests := []struct {
name string
guard Guard
plan *Plan
wantErr bool
}{
{
name: "无停用时通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}},
},
{
name: "超过绝对上限被拒",
guard: Guard{MaxDeactivate: 5},
plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}},
wantErr: true,
},
{
name: "超过比例上限被拒",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}},
wantErr: true,
},
{
name: "比例在阈值内通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}},
},
{
name: "强制模式跳过检查",
guard: Guard{MaxDeactivate: 1, AllowBulk: true},
plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.guard.Check(tt.plan)
if tt.wantErr && err == nil {
t.Errorf("期望返回错误,实际通过")
}
if !tt.wantErr && err != nil {
t.Errorf("期望通过,实际报错: %v", err)
}
})
}
}
func dumpPlan(p *Plan) string {
var b strings.Builder
for _, op := range p.Ops {
b.WriteString("\n ")
b.WriteString(string(op.Kind))
b.WriteString(" target=")
b.WriteString(op.Target)
b.WriteString(" detail=")
b.WriteString(op.Detail)
}
for _, w := range p.Warnings {
b.WriteString("\n WARN ")
b.WriteString(w)
}
return b.String()
}