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:
2026-09-29 23:48:55 +08:00
parent 979a98d502
commit 126e52a6ed
6 changed files with 835 additions and 1191 deletions
+182 -279
View File
@@ -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()
}
}