- 集成测试重构:syncer e2e/materialize、ldaplib 读路径、lldapgql 全改用本地 LLDAP 容器(LDAP 3890 / GraphQL /api/graphql 17170),删旧 OpenLDAP 断言 - 修复 1:ActiveUsers 不填 DepartmentIDs(存于关系表),BuildFromMirror 拿到空成员; 新增 ActiveUsersWithDepts,物化 plan 用之 - 修复 2:deleted 用户重新可见后不回 active,无法重建;RevertToActive 纳入 deleted, 快照 reconcile 对其置回 active + 作废 token - 关键安全回归单测保留并配变异验证:未确认不删、非受管不删、admin 永不移出、 删除最后阶段、绝不碰密码。全量单测+vet+集成全绿
260 lines
8.4 KiB
Go
260 lines
8.4 KiB
Go
package store
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mu 构造一个镜像用户,DepartmentIDs 用于驱动关系表重建。
|
|
func mu(uid, email, name string, deptIDs ...string) *MirrorUser {
|
|
return &MirrorUser{
|
|
UID: uid,
|
|
UnionID: "u-" + uid,
|
|
Email: email,
|
|
DisplayName: name,
|
|
SN: name,
|
|
GivenName: name,
|
|
EmployeeType: "Active",
|
|
DepartmentIDs: deptIDs,
|
|
State: StateActive,
|
|
}
|
|
}
|
|
|
|
// md 构造一个部门镜像。
|
|
func md(id, parent, name string) *MirrorDepartment {
|
|
return &MirrorDepartment{DeptID: id, ParentID: parent, Name: name}
|
|
}
|
|
|
|
// openStore 打开一个临时文件库,保证多事务测试有真实磁盘语义。
|
|
func openStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "idsync-test.db")
|
|
st, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("打开 store 失败: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return st
|
|
}
|
|
|
|
// TestReplaceMirrorTransactionalRollback 验证快照整体替换的事务性:
|
|
// 中途失败时必须回滚到上一版完整镜像,绝不留下半新半旧。
|
|
func TestReplaceMirrorTransactionalRollback(t *testing.T) {
|
|
st := openStore(t)
|
|
|
|
// 先写入一版正常镜像。
|
|
good := &MirrorSnapshot{
|
|
Departments: []*MirrorDepartment{md("od-root", "", "Acme")},
|
|
Users: []*MirrorUser{
|
|
mu("alice", "alice@example.com", "Alice", "od-root"),
|
|
},
|
|
}
|
|
if err := st.ReplaceMirror(good); err != nil {
|
|
t.Fatalf("初始镜像写入失败: %v", err)
|
|
}
|
|
|
|
// 第二版镜像故意带一个空部门名,触发 CHECK 约束失败,中断事务。
|
|
bad := &MirrorSnapshot{
|
|
Departments: []*MirrorDepartment{{DeptID: "od-bad", ParentID: "", Name: ""}},
|
|
Users: []*MirrorUser{
|
|
mu("bob", "bob@example.com", "Bob", "od-bad"),
|
|
},
|
|
}
|
|
if err := st.ReplaceMirror(bad); err == nil {
|
|
t.Fatalf("含非法部门名的镜像应当写失败,却成功了")
|
|
}
|
|
|
|
// 回滚后镜像必须仍是第一版:alice 还在、od-root 还在、bob 与 od-bad 都不在。
|
|
users, err := st.MapUsersByUID()
|
|
if err != nil {
|
|
t.Fatalf("读取用户镜像失败: %v", err)
|
|
}
|
|
if _, ok := users["alice"]; !ok {
|
|
t.Errorf("回滚后 alice 丢失,镜像被污染")
|
|
}
|
|
if _, ok := users["bob"]; ok {
|
|
t.Errorf("回滚后 bob 残留,镜像被污染")
|
|
}
|
|
|
|
depts, err := st.MapDepartmentsByID()
|
|
if err != nil {
|
|
t.Fatalf("读取部门镜像失败: %v", err)
|
|
}
|
|
if _, ok := depts["od-root"]; !ok {
|
|
t.Errorf("回滚后 od-root 丢失(清空操作也被回滚)")
|
|
}
|
|
if _, ok := depts["od-bad"]; ok {
|
|
t.Errorf("回滚后 od-bad 残留")
|
|
}
|
|
}
|
|
|
|
// TestReplaceMirrorPreservesLifecycleState 验证:快照刷新只覆盖飞书事实,
|
|
// 不清洗生命周期状态——正在走离职流程的用户不会被快照重置回 active。
|
|
func TestReplaceMirrorPreservesLifecycleState(t *testing.T) {
|
|
st := openStore(t)
|
|
snap := &MirrorSnapshot{
|
|
Departments: []*MirrorDepartment{md("od-root", "", "Acme")},
|
|
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice", "od-root")},
|
|
}
|
|
if err := st.ReplaceMirror(snap); err != nil {
|
|
t.Fatalf("初始写入失败: %v", err)
|
|
}
|
|
if err := st.ToLeaveCandidate("alice"); err != nil {
|
|
t.Fatalf("转入 leave_candidate 失败: %v", err)
|
|
}
|
|
|
|
// 用户仍可见于飞书,快照再次刷新。
|
|
if err := st.ReplaceMirror(snap); err != nil {
|
|
t.Fatalf("再次刷新失败: %v", err)
|
|
}
|
|
users, _ := st.MapUsersByUID()
|
|
if users["alice"].State != StateLeaveCandidate {
|
|
t.Errorf("快照刷新后 state = %q, 期望仍为 %q(生命周期不应被快照覆盖)",
|
|
users["alice"].State, StateLeaveCandidate)
|
|
}
|
|
// 关系表应重建无误。
|
|
ids, err := st.UserDeptIDs("alice")
|
|
if err != nil || len(ids) != 1 || ids[0] != "od-root" {
|
|
t.Errorf("关系表重建异常: %v -> %v", err, ids)
|
|
}
|
|
}
|
|
|
|
// TestRejectAbsentDepartmentsCleared 验证部门整体替换:快照之外的历史部门被清除,
|
|
// 保证镜像 = 飞书当前有的部门,不会残留幽灵组。
|
|
func TestRejectAbsentDepartmentsCleared(t *testing.T) {
|
|
st := openStore(t)
|
|
if err := st.ReplaceMirror(&MirrorSnapshot{
|
|
Departments: []*MirrorDepartment{md("od-old", "", "Old")},
|
|
}); err != nil {
|
|
t.Fatalf("初始写入失败: %v", err)
|
|
}
|
|
if err := st.ReplaceMirror(&MirrorSnapshot{
|
|
Departments: []*MirrorDepartment{md("od-new", "", "New")},
|
|
}); err != nil {
|
|
t.Fatalf("刷新失败: %v", err)
|
|
}
|
|
depts, _ := st.MapDepartmentsByID()
|
|
if _, ok := depts["od-new"]; !ok {
|
|
t.Errorf("新部门未写入")
|
|
}
|
|
if _, ok := depts["od-old"]; ok {
|
|
t.Errorf("快照之外的旧部门 od-old 仍残留在部门镜像中")
|
|
}
|
|
}
|
|
|
|
// TestStateMachine 验证生命周期状态机的合法转移与非法拦截。
|
|
func TestStateMachine(t *testing.T) {
|
|
st := openStore(t)
|
|
if err := st.ReplaceMirror(&MirrorSnapshot{
|
|
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")},
|
|
}); err != nil {
|
|
t.Fatalf("初始写入失败: %v", err)
|
|
}
|
|
|
|
// 合法链:active → leave_candidate → pending_confirm → confirmed_leave → deleted
|
|
checks := []struct {
|
|
name string
|
|
apply func() error
|
|
want string
|
|
}{
|
|
{"机器确认离职", func() error { return st.ToLeaveCandidate("alice") }, StateLeaveCandidate},
|
|
{"发卡片待确认", func() error { return st.ToPendingConfirm("alice") }, StatePendingConfirm},
|
|
{"人工确认离职", func() error { return st.ConfirmLeave("alice") }, StateConfirmedLeave},
|
|
{"物化删除", func() error { return st.MarkDeleted("alice") }, StateDeleted},
|
|
}
|
|
for _, c := range checks {
|
|
if err := c.apply(); err != nil {
|
|
t.Fatalf("%s 失败: %v", c.name, err)
|
|
}
|
|
assertState(t, st, "alice", c.want)
|
|
}
|
|
|
|
// 非法转移:deleted 状态不能转 pending_confirm(状态机被绕过必须报错)。
|
|
if err := st.ToPendingConfirm("alice"); err == nil {
|
|
t.Errorf("deleted 状态的用户应无法转 pending_confirm,却成功了")
|
|
}
|
|
// 已删除用户重新在飞书可见:revert 置回 active(重新入职/重建)。
|
|
if err := st.RevertToActive("alice"); err != nil {
|
|
t.Fatalf("RevertToActive 不应报错: %v", err)
|
|
}
|
|
assertState(t, st, "alice", StateActive)
|
|
|
|
// 双确认流程内可回退(重新入职)。
|
|
if err := st.ReplaceMirror(&MirrorSnapshot{
|
|
Users: []*MirrorUser{mu("bob", "bob@example.com", "Bob")},
|
|
}); err != nil {
|
|
t.Fatalf("写入 bob 失败: %v", err)
|
|
}
|
|
if err := st.ToLeaveCandidate("bob"); err != nil {
|
|
t.Fatalf("bob 转 leave_candidate 失败: %v", err)
|
|
}
|
|
if err := st.RevertToActive("bob"); err != nil {
|
|
t.Fatalf("bob revert 失败: %v", err)
|
|
}
|
|
assertState(t, st, "bob", StateActive)
|
|
}
|
|
|
|
// TestTokenOneTimeExpiryRevoke 验证一次性 token:试用即废、过期失效、作废后拒绝。
|
|
func TestTokenOneTimeExpiryRevoke(t *testing.T) {
|
|
st := openStore(t)
|
|
if err := st.ReplaceMirror(&MirrorSnapshot{
|
|
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")},
|
|
}); err != nil {
|
|
t.Fatalf("初始写入失败: %v", err)
|
|
}
|
|
|
|
// 正常 token:消费成功,返回 uid。
|
|
tok, err := st.CreateToken("alice", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("创建 token 失败: %v", err)
|
|
}
|
|
consumed, err := st.ConsumeToken(tok.Token)
|
|
if err != nil {
|
|
t.Fatalf("消费 token 失败: %v", err)
|
|
}
|
|
if consumed.UID != "alice" {
|
|
t.Errorf("消费返回 uid = %q, 期望 alice", consumed.UID)
|
|
}
|
|
// 再次使用同一 token 必须被拒(一次使用)。
|
|
if _, err := st.ConsumeToken(tok.Token); err == nil {
|
|
t.Errorf("已使用的 token 应被拒绝复用")
|
|
}
|
|
|
|
// 过期的 token:负 TTL 使 ExpiresAt 落在过去。
|
|
expired, err := st.CreateToken("alice", -time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("创建过期 token 失败: %v", err)
|
|
}
|
|
if _, err := st.ConsumeToken(expired.Token); err == nil || !strings.Contains(err.Error(), "过期") {
|
|
t.Errorf("过期 token 应报过期错误,实际: %v", err)
|
|
}
|
|
|
|
// 作废的 token:用户重新可见后 revoke,未用 token 拒绝消费。
|
|
pending, err := st.CreateToken("alice", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("创建 token 失败: %v", err)
|
|
}
|
|
if err := st.RevokeTokensForUser("alice"); err != nil {
|
|
t.Fatalf("作废 token 失败: %v", err)
|
|
}
|
|
if _, err := st.ConsumeToken(pending.Token); err == nil {
|
|
t.Errorf("已作废的 token 应被拒绝消费")
|
|
}
|
|
}
|
|
|
|
func assertState(t *testing.T, st *Store, uid, want string) {
|
|
t.Helper()
|
|
users, err := st.MapUsersByUID()
|
|
if err != nil {
|
|
t.Fatalf("读取用户失败: %v", err)
|
|
}
|
|
u, ok := users[uid]
|
|
if !ok {
|
|
t.Fatalf("用户 %s 不存在", uid)
|
|
}
|
|
if u.State != want {
|
|
t.Errorf("用户 %s state = %q, 期望 %q", uid, u.State, want)
|
|
}
|
|
} |