feat: store 镜像层(全字段镜像+部门关系+生命周期+一次性 token)
新增镜像存储层并保留旧台账过渡: - MirrorUser/MirrorDepartment/MirrorUserDepartment/ResignConfirmToken 模型 - ReplaceMirror 单事务整体替换:用户/部门/关系一致更新,失败回滚保留旧版 - 生命周期状态机:active→leave_candidate→pending_confirm→confirmed_leave→deleted, 非法转移结构性报错;快照只覆盖飞书事实不清洗状态 - 一次性离职确认 token:crypto/rand 生成、一次使用、过期作废、可批量 revoke - 单测:事务回滚、状态机转移、token 一次/过期/复用被拒 全部通过 - docs/migration-baseline.md 记录阶段0基线字段
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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,却成功了")
|
||||
}
|
||||
// deleted 后 revert 不应生效(已物理删除,不能回退,交给「重建」处理)。
|
||||
if err := st.RevertToActive("alice"); err != nil {
|
||||
t.Fatalf("RevertToActive 不应报错: %v", err)
|
||||
}
|
||||
assertState(t, st, "alice", StateDeleted)
|
||||
|
||||
// 双确认流程内可回退(重新入职)。
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user