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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user