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=od-yanfa,ou=groups,dc=example,dc=com" { t.Errorf("od-yanfa DN = %+v, 期望索引派生 cn=", 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 } } }