飞书通讯录 ↔ 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/)不入库。
436 lines
13 KiB
Go
436 lines
13 KiB
Go
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()
|
||
}
|