Files
idsync/internal/syncer/plan_test.go
T
caowangrenbo 126e52a6ed 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 永不移出、删除最后阶段
2026-09-29 23:48:55 +08:00

338 lines
10 KiB
Go

package syncer
import (
"strings"
"testing"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
)
const (
peopleOU = "ou=people,dc=example,dc=com"
groupRoot = "ou=groups,dc=example,dc=com"
testAdmin = "uid=admin," + peopleOU
)
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
return ldaplib.NewEntry(dn, attrs, members)
}
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 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,
ActualUsers: map[string]*ldaplib.Entry{},
ActualGroups: map[string]*ldaplib.Entry{},
OwnedUsers: map[string]struct{}{},
ConfirmedLeave: map[string]bool{},
AdminDN: testAdmin,
PeopleOU: peopleOU,
GroupRootDN: groupRoot,
}
}
func kindsOf(plan *Plan) map[OpKind]int {
out := make(map[OpKind]int)
for _, op := range plan.Ops {
out[op.Kind]++
}
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.DesiredState
mutate func(in *Input)
wantKinds map[OpKind]int
forbid []OpKind
wantWarn string
}{
{
name: "全新用户:创建",
desired: withUsers(plainDesired(), dUser("alice")),
wantKinds: map[OpKind]int{OpCreateUser: 1},
},
{
name: "属性一致:不产生操作",
desired: withUsers(plainDesired(), dUser("alice")),
mutate: func(in *Input) {
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.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{},
},
{
name: "邮箱变化:更新属性",
desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
mutate: func(in *Input) {
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.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{OpUpdateUser: 1},
},
{
name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
desired: plainDesired(),
mutate: func(in *Input) {
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{OpDeleteUser: 1},
},
{
name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
desired: plainDesired(),
mutate: func(in *Input) {
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
in.OwnedUsers["bob"] = struct{}{}
},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpDeleteUser},
wantWarn: "未完成离职确认",
},
{
name: "在 LLDAP、非受管(服务账号):绝不删除",
desired: plainDesired(),
mutate: func(in *Input) {
in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpDeleteUser},
},
{
name: "分组不存在:创建",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
wantKinds: map[OpKind]int{OpCreateGroup: 1},
},
{
name: "孤儿组(actual 有、期望没有):不删只告警",
desired: plainDesired(),
mutate: func(in *Input) {
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
map[string]string{ldaplib.AttrDescription: "x"}, nil)
},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpCreateGroup},
wantWarn: "未自动删除",
},
{
name: "成员缺席:加入分组",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
mutate: func(in *Input) {
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
in.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{OpAddMember: 1},
},
{
name: "成员多余且受管:移出分组",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
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: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
[]string{testAdmin})
in.OwnedUsers["admin"] = struct{}{}
},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpRemoveMember},
},
{
name: "外部非受管成员:不移出,仅告警",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
[]string{testAdmin, "uid=external," + peopleOU})
},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpRemoveMember},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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))
}
}
for kind, count := range got {
if _, exp := tt.wantKinds[kind]; !exp {
t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
}
}
for _, kind := range tt.forbid {
if got[kind] > 0 {
t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan))
}
}
if tt.wantWarn != "" {
found := false
for _, w := range plan.Warnings {
if strings.Contains(w, tt.wantWarn) {
found = true
break
}
}
if !found {
t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
}
}
})
}
}
// 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") ||
strings.Contains(strings.ToLower(op.Detail), "password") {
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
}
}
}
func TestGuard(t *testing.T) {
tests := []struct {
name string
guard Guard
plan *Plan
wantErr bool
}{
{"无删除时通过", 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("期望报错,实际通过")
}
if !tt.wantErr && err != nil {
t.Errorf("期望通过,实际报错: %v", err)
}
})
}
}
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(string(op.Kind))
b.WriteString("] ")
b.WriteString(op.Target)
b.WriteString(": ")
b.WriteString(op.Detail)
}
for _, w := range p.Warnings {
b.WriteString("\n WARN ")
b.WriteString(w)
}
return b.String()
}