Files
caowangrenbo 0fffdd5ccb test: 集成测试改用真 LLDAP 容器;修复两处物化缺陷
- 集成测试重构: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+集成全绿
2026-09-29 23:53:24 +08:00

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