feat: server 一次性离职确认端点 + WebUI 重构
- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth - WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、 概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、 部门分组展示 groupId;重编译 app.css/app.js 并提交产物 - 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user