Files
idsync/internal/syncer/snapshot_test.go
T
caowangrenbo f997e48a75 feat: feishu 离职卡片 + 快照 runner(快照→镜像→离职流程)
快照段(飞书→SQLite 镜像→离职双确认推进)落地,不碰 LLDAP:
- feishu 新增离职确认卡片:自定义机器人 webhook 发送 interactive 卡片,
  确认按钮 url 跳程序自己的一次性端点;本地假 webhook 单测验证载荷与错误处理
- config.ConfirmURL/ResignConfirmRoute:确认端点路由与 URL 拼接(server/syncer 共用避免循环)
- mapping.FlatDeptInfo:部门树拍平为拼音点分命名并计算 DN(纯函数),
  供快照落库持久化 pinyin_name/dn,物化与 WebUI 复用
- SnapshotRunner.RunSnapshot:拉飞书→事务写镜像→离职生命周期 reconcile
  (机器确认→token+卡片→pending_confirm;节流防刷屏;重新可见撤销并作废 token)
- 链路单测:快照落库、离职机器确认、发卡、状态机转移、节流、重新入职全部通过
2026-09-29 23:24:39 +08:00

343 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package syncer
// 快照 runner 的本地链路测试:用一个假飞书 HTTP 服务 + 一个假 webhook,
// 验证「拉快照 → 落库镜像 → 离职机器确认 → 发卡片 → 状态机转移 → 节流 → 重新入职」。
//
// 不需要真实飞书凭据,也不需要 LLDAP——快照段根本不碰 LLDAP。
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/store"
)
// sfDept 是假飞书里的部门。
type sfDept struct {
openID string
parentID string
name string
}
// sfUser 是假飞书里的用户。
type sfUser struct {
unionID string
name string
enName string
enterpriseEm string
mobile string
employeeNo string
jobTitle string
deptIDs []string
resigned bool
}
// sfFeishu 只实现快照链路需要的接口。
type sfFeishu struct {
depts []sfDept
users []sfUser
}
func (f *sfFeishu) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeSf(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
})
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
parent := r.URL.Query().Get("parent_department_id")
if parent == "" {
parent = "0"
}
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true}
if fetchChild {
for changed := true; changed; {
changed = false
for _, d := range f.depts {
if !want[d.openID] && want[d.parentID] {
want[d.openID] = true
changed = true
}
}
}
}
items := make([]map[string]any, 0)
for _, d := range f.depts {
if !want[d.openID] || d.openID == parent {
continue
}
items = append(items, map[string]any{
"name": d.name,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
})
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0)
for _, u := range f.users {
if u.resigned {
continue
}
if !contains(u.deptIDs, deptID) {
continue
}
items = append(items, map[string]any{
"union_id": u.unionID,
"name": u.name,
"en_name": u.enName,
"enterprise_email": u.enterpriseEm,
"mobile": u.mobile,
"employee_no": u.employeeNo,
"job_title": u.jobTitle,
"department_ids": u.deptIDs,
})
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1]
for _, u := range f.users {
if u.unionID != unionID {
continue
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{
"union_id": unionID,
"name": u.name,
"status": map[string]any{"is_resigned": u.resigned},
}}})
return
}
writeSf(w, map[string]any{"code": 10001, "msg": "user not found"})
})
return mux
}
func writeSf(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// fakeWebhook 是假 webhook,统计收到卡片的次数并返回成功。
type fakeWebhook struct {
posts int
body []byte
srv *httptest.Server
}
func newFakeWebhook() *fakeWebhook {
fw := &fakeWebhook{}
fw.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fw.posts++
fw.body, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
}))
return fw
}
func (fw *fakeWebhook) Close() { fw.srv.Close() }
// snapshotHarness 组装快照 runner 所需的整套环境。
type snapshotHarness struct {
fs *sfFeishu
webhook *fakeWebhook
store *store.Store
runner *SnapshotRunner
cfg *config.Config
}
func newSnapshotHarness(t *testing.T) *snapshotHarness {
t.Helper()
fs := &sfFeishu{depts: []sfDept{
{openID: "od-acme", parentID: "0", name: "Acme"},
{openID: "od-yanfa", parentID: "od-acme", name: "yanfa"},
}}
fs.users = []sfUser{
{unionID: "u-alice", name: "Zhang San", enName: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-yanfa"}},
{unionID: "u-bob", name: "Li Ming", enName: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-yanfa"}},
}
srv := httptest.NewServer(fs.handler())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
fc, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake", BaseURL: srv.URL, QPS: 100, Timeout: 10 * time.Second,
}, log)
if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "idsync.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
cfg := &config.Config{
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: config.Ldap{BaseDN: "dc=example,dc=com"},
}
webhook := newFakeWebhook()
runner := NewSnapshotRunner(cfg, fc, st, log, SnapshotOptions{
WebhookURL: webhook.srv.URL,
ConfirmBaseURL: "https://confirm.example.com",
ReNotifyInterval: 24 * time.Hour,
TokenTTL: 72 * time.Hour,
})
t.Cleanup(func() {
srv.Close()
webhook.Close()
_ = st.Close()
})
return &snapshotHarness{fs: fs, webhook: webhook, store: st, runner: runner, cfg: cfg}
}
func (h *snapshotHarness) run(t *testing.T) *SnapshotRunResult {
t.Helper()
res, err := h.runner.RunSnapshot(t.Context())
if err != nil {
t.Fatalf("RunSnapshot 失败: %v", err)
}
return res
}
func userState(t *testing.T, h *snapshotHarness, uid string) string {
t.Helper()
m, err := h.store.MapUsersByUID()
if err != nil {
t.Fatalf("读取镜像失败: %v", err)
}
u, ok := m[uid]
if !ok {
t.Fatalf("镜像中不存在 uid=%s", uid)
}
return u.State
}
// TestSnapshotLeaveLifecycle 覆盖完整链路:快照落库 → 离职机器确认 → 发卡片 →
// 状态机转移 → 节流不刷屏 → 重新入职撤销离职。
func TestSnapshotLeaveLifecycle(t *testing.T) {
h := newSnapshotHarness(t)
// 第 1 轮:全员在职,只落库,不发卡片、不产生离职候选。
res := h.run(t)
if res.CardsSent != 0 || res.LeaveCandidates != 0 || res.PendingConfirm != 0 {
t.Errorf("首轮不应触发离职流程: %+v", res)
}
if res.Users != 2 {
t.Errorf("第 1 轮应落 2 个用户,实际 %d", res.Users)
}
if userState(t, h, "bob") != store.StateActive {
t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob"))
}
// 部门镜像应持久化拍平拼音与 DN。
dm, err := h.store.MapDepartmentsByID()
if err != nil {
t.Fatalf("读取部门镜像失败: %v", err)
}
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=Acme.yanfa,ou=groups,dc=example,dc=com" {
t.Errorf("od-yanfa DN = %+v, 期望拍平拼音", d)
}
if d := dm["od-yanfa"]; d == nil || d.PinyinName != "Acme.yanfa" {
t.Errorf("od-yanfa PinyinName = %+v", d)
}
// 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),
// 但 Get 仍能查到并返回 is_resigned=true → 应机器确认、发 1 张卡片并转入 pending_confirm。
markResigned(h.fs, "u-bob", true)
res = h.run(t)
if res.CardsSent != 1 {
t.Errorf("bob 离职应发 1 张卡片,实际 %d", res.CardsSent)
}
if res.PendingConfirm != 1 {
t.Errorf("bob 应转入 pending_confirm,实际 %d", res.PendingConfirm)
}
if userState(t, h, "bob") != store.StatePendingConfirm {
t.Errorf("bob 应为 pending_confirm,实际 %q", userState(t, h, "bob"))
}
// 卡片应指向一次性确认端点。
if !strings.Contains(string(h.webhook.body), "/api/resign/confirm") {
t.Errorf("卡片载荷应包含确认端点地址")
}
// 第 3 轮(立即):bob 仍缺席且待确认,应被节流,不再发卡片。
res = h.run(t)
if h.webhook.posts != 1 {
t.Errorf("节流未生效:离上次提醒不足间隔又发出了卡片,当前 %d 次", h.webhook.posts)
}
// 第 4 轮:bob 重新可见 → 撤销离职流程并作废 token。
markResigned(h.fs, "u-bob", false)
res = h.run(t)
if res.Reinstated != 1 {
t.Errorf("bob 重新入职应计 1 次撤销,实际 %d", res.Reinstated)
}
if userState(t, h, "bob") != store.StateActive {
t.Errorf("bob 撤销后应为 active,实际 %q", userState(t, h, "bob"))
}
toks, _ := h.store.LastPendingToken("bob")
if toks != nil {
t.Errorf("bob 的 token 应已作废,仍有 pending 记录")
}
}
// TestSnapshotSkippedUserRecorded 验证缺少企业邮箱的用户被跳过但落了 skipped 记录。
func TestSnapshotSkippedUserRecorded(t *testing.T) {
h := newSnapshotHarness(t)
h.fs.users = []sfUser{
{unionID: "u-nomail", name: "Wang Wu", enterpriseEm: "", deptIDs: []string{"od-yanfa"}},
}
res := h.run(t)
if res.Skipped != 1 {
t.Errorf("应跳过 1 个无邮箱用户,实际 %d", res.Skipped)
}
if res.Users != 0 {
t.Errorf("无邮箱用户不应进入在职视图,实际 %d", res.Users)
}
skipped, err := h.store.SkippedUsers()
if err != nil || len(skipped) != 1 {
t.Fatalf("应有一条 skipped 记录: %v", err)
}
if skipped[0].UID != "u-nomail" {
t.Errorf("skipped 记录 uid = %q, 期望以 union_id 落库", skipped[0].UID)
}
}
func markResigned(f *sfFeishu, unionID string, resigned bool) {
for i := range f.users {
if f.users[i].unionID == unionID {
f.users[i].resigned = resigned
}
}
}