Files
idsync/internal/syncer/snapshot_test.go
T
caowangrenbo 979a98d502 refactor: config + lldaplib 收敛到 LLDAP 形态(读只读、删 OpenLDAP 写路径与密码包)
- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class /
  group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url
  + 两套 schedule + guard
- ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password
- config.example.yml 重写成 LLDAP 形态
- 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份,
  mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
2026-09-29 23:48:49 +08:00

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