feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server
- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际 LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段; 所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删 - exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射 (物化开始时经 GraphQL Groups 拉取),不凭名字反推 - runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、 全成功才回写 attrs_hash/groupId/MarkDeleted - main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config - server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点 (不经 Auth,token 即凭证)、skipped 展示 - 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、 admin 永不移出、删除最后阶段
This commit is contained in:
+182
-279
@@ -9,43 +9,42 @@ import (
|
||||
)
|
||||
|
||||
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"
|
||||
peopleOU = "ou=people,dc=example,dc=com"
|
||||
groupRoot = "ou=groups,dc=example,dc=com"
|
||||
testAdmin = "uid=admin," + peopleOU
|
||||
)
|
||||
|
||||
// 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 dUser(uid string) *mapping.DesiredUser {
|
||||
return &mapping.DesiredUser{
|
||||
UID: uid, DN: "uid=" + uid + "," + peopleOU,
|
||||
Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid,
|
||||
}
|
||||
}
|
||||
|
||||
func baseInput(desired *mapping.State) *Input {
|
||||
func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup {
|
||||
ms := make(map[string]struct{}, len(members))
|
||||
for _, m := range members {
|
||||
ms[m] = struct{}{}
|
||||
}
|
||||
return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms}
|
||||
}
|
||||
|
||||
func depName(deptID string) string { return "部门-" + deptID }
|
||||
|
||||
func baseIn(desired *mapping.DesiredState) *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,
|
||||
Desired: desired,
|
||||
ActualUsers: map[string]*ldaplib.Entry{},
|
||||
ActualGroups: map[string]*ldaplib.Entry{},
|
||||
OwnedUsers: map[string]struct{}{},
|
||||
ConfirmedLeave: map[string]bool{},
|
||||
AdminDN: testAdmin,
|
||||
PeopleOU: peopleOU,
|
||||
GroupRootDN: groupRoot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,313 +56,224 @@ func kindsOf(plan *Plan) map[OpKind]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func plainDesired() *mapping.DesiredState {
|
||||
return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}}
|
||||
}
|
||||
|
||||
func TestComputePlan(t *testing.T) {
|
||||
rdDN := "cn=od-rd," + groupRoot
|
||||
tests := []struct {
|
||||
name string
|
||||
desired *mapping.State
|
||||
desired *mapping.DesiredState
|
||||
mutate func(in *Input)
|
||||
wantKinds map[OpKind]int
|
||||
// wantDetailContains 断言某个操作详情包含特定文本
|
||||
wantDetailContains string
|
||||
// forbidKinds 断言这些操作绝不出现(安全性回归)
|
||||
forbidKinds []OpKind
|
||||
forbid []OpKind
|
||||
wantWarn string
|
||||
}{
|
||||
{
|
||||
name: "全新用户:创建",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("alice")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "全新用户:创建",
|
||||
desired: withUsers(plainDesired(), dUser("alice")),
|
||||
wantKinds: map[OpKind]int{OpCreateUser: 1},
|
||||
},
|
||||
{
|
||||
name: "属性一致:不产生操作",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("alice")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "属性一致:不产生操作",
|
||||
desired: withUsers(plainDesired(), dUser("alice")),
|
||||
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,
|
||||
u := dUser("alice")
|
||||
in.ActualUsers["alice"] = entry(u.DN, map[string]string{
|
||||
ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName,
|
||||
ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN,
|
||||
}, nil)
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
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{},
|
||||
},
|
||||
name: "邮箱变化:更新属性",
|
||||
desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
|
||||
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",
|
||||
in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{
|
||||
ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice",
|
||||
ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice",
|
||||
}, nil)
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpUpdateUser: 1},
|
||||
wantDetailContains: "alice.new@example.com",
|
||||
wantKinds: map[OpKind]int{OpUpdateUser: 1},
|
||||
},
|
||||
{
|
||||
name: "台账在职但飞书消失:停用",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Bob",
|
||||
}, nil)
|
||||
in.LedgerAccounts["bob"] = "active"
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Bob"}, nil)
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
in.ConfirmedLeave["bob"] = true
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpDeactivate: 1},
|
||||
wantKinds: map[OpKind]int{OpDeleteUser: 1},
|
||||
},
|
||||
{
|
||||
name: "非台账账号缺席:不得停用(关键安全回归)",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
|
||||
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Service",
|
||||
}, nil)
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpDeactivate},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []OpKind{OpDeleteUser},
|
||||
wantWarn: "未完成离职确认",
|
||||
},
|
||||
{
|
||||
name: "在 Inactive 且台账受管:恢复",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("carol")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、非受管(服务账号):绝不删除",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Carol",
|
||||
}, nil)
|
||||
in.LedgerAccounts["carol"] = "inactive"
|
||||
in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
|
||||
in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpReactivate: 1},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []OpKind{OpDeleteUser},
|
||||
},
|
||||
{
|
||||
name: "在 Inactive 但非台账:只告警不改动",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("dave")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "分组不存在:创建",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
wantKinds: map[OpKind]int{OpCreateGroup: 1},
|
||||
},
|
||||
{
|
||||
name: "孤儿组(actual 有、期望没有):不删只告警",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "x"}, nil)
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpReactivate},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []OpKind{OpCreateGroup},
|
||||
wantWarn: "未自动删除",
|
||||
},
|
||||
{
|
||||
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{},
|
||||
},
|
||||
name: "成员缺席:加入分组",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
|
||||
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"
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
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{},
|
||||
},
|
||||
name: "成员多余且受管:移出分组",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
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"
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin, "uid=alice," + peopleOU})
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
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{},
|
||||
},
|
||||
name: "admin 永不被移出分组(关键安全回归)",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
mutate: func(in *Input) {
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{testAdminDN})
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin})
|
||||
in.OwnedUsers["admin"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpRemoveMember},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []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{},
|
||||
},
|
||||
name: "外部非受管成员:不移出,仅告警",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
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})
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin, "uid=external," + peopleOU})
|
||||
},
|
||||
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},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []OpKind{OpRemoveMember},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := baseInput(tt.desired)
|
||||
in := baseIn(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))
|
||||
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))
|
||||
if _, exp := tt.wantKinds[kind]; !exp {
|
||||
t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
for _, kind := range tt.forbidKinds {
|
||||
for _, kind := range tt.forbid {
|
||||
if got[kind] > 0 {
|
||||
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
|
||||
t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
if tt.wantDetailContains != "" {
|
||||
if tt.wantWarn != "" {
|
||||
found := false
|
||||
for _, op := range plan.Ops {
|
||||
if strings.Contains(op.Detail, tt.wantDetailContains) {
|
||||
for _, w := range plan.Warnings {
|
||||
if strings.Contains(w, tt.wantWarn) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
|
||||
tt.wantDetailContains, dumpPlan(plan))
|
||||
t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。
|
||||
func TestDeleteIsLastPhase(t *testing.T) {
|
||||
rdDN := "cn=od-rd," + groupRoot
|
||||
desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice"))
|
||||
in := baseIn(desired)
|
||||
in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{
|
||||
ldaplib.AttrDisplayName: "alice"}, nil)
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
||||
in.ConfirmedLeave["bob"] = true
|
||||
in.OwnedUsers["carol"] = struct{}{}
|
||||
in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil)
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU})
|
||||
|
||||
plan := ComputePlan(in)
|
||||
|
||||
// bob:删除;alice 创建;carol 移出分组。
|
||||
// 任何删除操作都必须排在任何创建/加/移之后。
|
||||
maxOther, minDelete := -1, 1<<30
|
||||
for i, op := range plan.Ops {
|
||||
if op.Kind == OpDeleteUser {
|
||||
if i < minDelete {
|
||||
minDelete = i
|
||||
}
|
||||
} else {
|
||||
if i > maxOther {
|
||||
maxOther = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if minDelete <= maxOther {
|
||||
t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePlanNeverTouchesPassword 是结构性回归:
|
||||
// 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。
|
||||
func TestComputePlanNeverTouchesPassword(t *testing.T) {
|
||||
in := baseIn(withUsers(plainDesired(), dUser("alice")))
|
||||
plan := ComputePlan(in)
|
||||
for _, op := range plan.Ops {
|
||||
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
|
||||
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
|
||||
if strings.Contains(strings.ToLower(op.Detail), "userpassword") ||
|
||||
strings.Contains(strings.ToLower(op.Detail), "password") {
|
||||
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,40 +285,17 @@ func TestGuard(t *testing.T) {
|
||||
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}},
|
||||
},
|
||||
{"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false},
|
||||
{"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true},
|
||||
{"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true},
|
||||
{"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false},
|
||||
{"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false},
|
||||
}
|
||||
|
||||
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("期望返回错误,实际通过")
|
||||
t.Errorf("期望报错,实际通过")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("期望通过,实际报错: %v", err)
|
||||
@@ -417,14 +304,30 @@ func TestGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState {
|
||||
s.Users = append(s.Users, us...)
|
||||
return s
|
||||
}
|
||||
|
||||
func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState {
|
||||
s.Groups = append(s.Groups, gs...)
|
||||
if s.GroupByDeptID == nil {
|
||||
s.GroupByDeptID = map[string]*mapping.DesiredGroup{}
|
||||
}
|
||||
for _, g := range gs {
|
||||
s.GroupByDeptID[g.DeptID] = g
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func dumpPlan(p *Plan) string {
|
||||
var b strings.Builder
|
||||
for _, op := range p.Ops {
|
||||
b.WriteString("\n ")
|
||||
b.WriteString("\n [")
|
||||
b.WriteString(string(op.Kind))
|
||||
b.WriteString(" target=")
|
||||
b.WriteString("] ")
|
||||
b.WriteString(op.Target)
|
||||
b.WriteString(" detail=")
|
||||
b.WriteString(": ")
|
||||
b.WriteString(op.Detail)
|
||||
}
|
||||
for _, w := range p.Warnings {
|
||||
@@ -432,4 +335,4 @@ func dumpPlan(p *Plan) string {
|
||||
b.WriteString(w)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user