feat: server 一次性离职确认端点 + WebUI 重构

- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth
- WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、
  概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、
  部门分组展示 groupId;重编译 app.css/app.js 并提交产物
- 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
This commit is contained in:
2026-09-29 23:56:30 +08:00
parent 0fffdd5ccb
commit e187369821
6 changed files with 346 additions and 127 deletions
+105
View File
@@ -0,0 +1,105 @@
package server
// 一次性离职确认端点的单元测试:有效/过期/复用/串用 token 各测一遍。
// 该端点不经 Auth 中间件,token 即凭证,因此测试直接打到 Server 的 mux。
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/store"
)
func newConfirmServer(t *testing.T, st *store.Store) *Server {
t.Helper()
auth, err := NewAuth("admin", nil, "pw", make([]byte, 32), false)
if err != nil {
t.Fatalf("构造认证失败: %v", err)
}
return New(&Deps{Store: st, Auth: auth, Cfg: &config.Config{}})
}
func confirmGET(s *Server, uid, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/resign/confirm?uid="+uid+"&token="+token, nil)
rec := httptest.NewRecorder()
s.mux.ServeHTTP(rec, req)
return rec
}
func seedPendingUser(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "idsync.db"))
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
if err := st.ReplaceMirror(&store.MirrorSnapshot{
Users: []*store.MirrorUser{{UID: "alice", UnionID: "u-alice", Email: "a@x.com"}},
}); err != nil {
t.Fatalf("灌镜像失败: %v", err)
}
for _, fn := range []func() error{
func() error { return st.ToLeaveCandidate("alice") },
func() error { return st.ToPendingConfirm("alice") },
} {
if err := fn(); err != nil {
t.Fatalf("状态转移失败: %v", err)
}
}
return st
}
func assertState(t *testing.T, st *store.Store, want string) {
t.Helper()
m, _ := st.MapUsersByUID()
if u := m["alice"]; u == nil || u.State != want {
t.Fatalf("alice state = %+v, 期望 %s", m["alice"], want)
}
}
func TestResignConfirmEndpoint(t *testing.T) {
st := seedPendingUser(t)
defer st.Close()
s := newConfirmServer(t, st)
// 有效 token:确认成功,状态置 confirmed_leave。
valid, err := st.CreateToken("alice", time.Hour)
if err != nil {
t.Fatalf("创建 token 失败: %v", err)
}
rec := confirmGET(s, "alice", valid.Token)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "已确认离职") {
t.Errorf("有效 token 应成功,code=%d body=%q", rec.Code, rec.Body.String())
}
assertState(t, st, store.StateConfirmedLeave)
// 复用同一 token:一次使用,必须被拒。
rec = confirmGET(s, "alice", valid.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("复用已用 token 应被拒,body=%q", rec.Body.String())
}
// 过期 token:拒绝。
expired, _ := st.CreateToken("alice", -time.Minute)
rec = confirmGET(s, "alice", expired.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("过期 token 应被拒,body=%q", rec.Body.String())
}
// uid 与 token 不一致(串用):拒绝。
other, _ := st.CreateToken("alice", time.Hour)
rec = confirmGET(s, "bob", other.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("uid 与 token 不一致应被拒,body=%q", rec.Body.String())
}
// 乱写的 token:拒绝。
rec = confirmGET(s, "alice", "garbage-token")
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("无效 token 应被拒,body=%q", rec.Body.String())
}
}