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) } }