package api_test import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "gorm.io/gorm" "ws_usernode/internal/api" "ws_usernode/internal/auth" "ws_usernode/internal/config" "ws_usernode/internal/model" "ws_usernode/internal/router" "ws_usernode/internal/service" "ws_usernode/internal/system" ) // recordingMailer 捕获邮件,用于从重置邮件提取 token 等。 type recordingMailer struct { lastTo string lastSubject string lastBody string } func (m *recordingMailer) Send(_ context.Context, to, subject, body string) error { m.lastTo = to m.lastSubject = subject m.lastBody = body return nil } // testApp 完整组装的应用(SQLite 内存库 + dry-run 系统层)。 type testApp struct { r http.Handler db *gorm.DB captchas auth.CaptchaStore otps auth.OTPStore mailer *recordingMailer } func setupTestApp(t *testing.T) *testApp { t.Helper() gin.SetMode(gin.TestMode) db, err := model.Open("sqlite", ":memory:", false) if err != nil { t.Fatalf("open db: %v", err) } if err := model.Migrate(db); err != nil { t.Fatalf("migrate: %v", err) } cfg := config.Default() cfg.System.DryRun = true // 集成测试走 dry-run,不触碰真实系统账号 sys := system.New(cfg.System) adminSvc := service.NewAdminService(db) userSvc := service.NewUserService(db, sys, cfg) auditSvc := service.NewAuditService(db) captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL) otps := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin) sessions := auth.NewDBSessionStore(db) resets := auth.NewDBResetTokenStore(db) limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration) mailer := &recordingMailer{} log := slog.New(slog.NewTextHandler(io.Discard, nil)) authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log) h := api.New(cfg, authSvc, userSvc, auditSvc) r := router.New(cfg, h, sessions, log) if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil { t.Fatalf("seed admin: %v", err) } return &testApp{r: r, db: db, captchas: captchas, otps: otps, mailer: mailer} } // doJSON 发起 JSON 请求,返回 recorder。 func (a *testApp) doJSON(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder { var rdr io.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } req := httptest.NewRequest(method, path, rdr) if body != nil { req.Header.Set("Content-Type", "application/json") } for _, c := range cookies { req.AddCookie(c) } w := httptest.NewRecorder() a.r.ServeHTTP(w, req) return w } func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { t.Fatalf("decode response %q: %v", w.Body.String(), err) } return m } func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, c := range w.Result().Cookies() { if c.Name == api.SessionCookieName { return c } } t.Fatalf("no session cookie in response") return nil } func TestAPICaptcha(t *testing.T) { app := setupTestApp(t) w := app.doJSON(http.MethodGet, "/api/v1/auth/captcha", nil) if w.Code != http.StatusOK { t.Fatalf("captcha status = %d, body=%s", w.Code, w.Body.String()) } m := decodeBody(t, w) data := m["data"].(map[string]any) if data["captcha_id"] == "" || data["image"] == "" { t.Fatalf("captcha response missing fields: %v", data) } } func TestAPIAdminLoginRequiresSession(t *testing.T) { app := setupTestApp(t) // 未登录访问 /users 应 401 w := app.doJSON(http.MethodGet, "/api/v1/users", nil) if w.Code != http.StatusUnauthorized { t.Fatalf("unauth users status = %d, want 401", w.Code) } // 错误密码 401 w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "bad"}) if w.Code != http.StatusUnauthorized { t.Fatalf("bad login status = %d, want 401", w.Code) } } func TestAPIAdminUserLifecycle(t *testing.T) { app := setupTestApp(t) // 管理员登录 w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"}) if w.Code != http.StatusOK { t.Fatalf("admin login status = %d, body=%s", w.Code, w.Body.String()) } ck := sessionCookie(t, w) // me w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, ck) if w.Code != http.StatusOK { t.Fatalf("me status = %d", w.Code) } if m := decodeBody(t, w); m["data"].(map[string]any)["username"] != "root" { t.Fatalf("me body = %s", w.Body.String()) } // 创建用户(dry-run 系统层) w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{ "username": "zhangsan", "email": "zs@example.com", "supervisor": "prof.li", "purpose": "科研", "ttl_days": 90, }, ck) if w.Code != http.StatusOK { t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String()) } created := decodeBody(t, w)["data"].(map[string]any) id := uint(created["id"].(float64)) if created["username"] != "ext_zhangsan" { t.Fatalf("created username = %v", created["username"]) } // 列表 w = app.doJSON(http.MethodGet, "/api/v1/users?status=active&page=1&page_size=10", nil, ck) if w.Code != http.StatusOK { t.Fatalf("list status = %d", w.Code) } list := decodeBody(t, w)["data"].(map[string]any) if list["total"].(float64) != 1 { t.Fatalf("list total = %v, want 1", list["total"]) } // 详情 w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(id), nil, ck) if w.Code != http.StatusOK { t.Fatalf("get status = %d, body=%s", w.Code, w.Body.String()) } // 更新(改邮箱) email := "zs-new@example.com" w = app.doJSON(http.MethodPatch, "/api/v1/users/"+itoa(id), map[string]any{"email": email}, ck) if w.Code != http.StatusOK { t.Fatalf("update status = %d, body=%s", w.Code, w.Body.String()) } // 禁用 → 启用 → 延期 for _, action := range []string{"disable", "enable", "extend"} { body := any(nil) if action == "extend" { body = map[string]any{"days": 30} } w = app.doJSON(http.MethodPost, "/api/v1/users/"+itoa(id)+"/"+action, body, ck) if w.Code != http.StatusOK { t.Fatalf("%s status = %d, body=%s", action, w.Code, w.Body.String()) } } // 删除 w = app.doJSON(http.MethodDelete, "/api/v1/users/"+itoa(id), nil, ck) if w.Code != http.StatusOK { t.Fatalf("delete status = %d, body=%s", w.Code, w.Body.String()) } // 审计应已写入 var n int64 if err := app.db.Model(&model.AuditLog{}).Count(&n).Error; err != nil { t.Fatalf("audit count: %v", err) } if n < 7 { t.Fatalf("audit entries = %d, want >= 7", n) } } func TestAPIUserOTPLogin(t *testing.T) { app := setupTestApp(t) // 管理员登录并创建外部用户 w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"}) ck := sessionCookie(t, w) w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{"username": "lisi", "email": "ls@example.com"}, ck) if w.Code != http.StatusOK { t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String()) } // 生成图形验证码(直接经 store,模拟用户看到验证码) cap, err := app.captchas.New() if err != nil { t.Fatalf("captcha new: %v", err) } // 发送 OTP w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]any{ "username": "ext_lisi", "captcha_id": cap.ID, "captcha_code": cap.Text, }) if w.Code != http.StatusOK { t.Fatalf("otp send status = %d, body=%s", w.Code, w.Body.String()) } // CLI 通道取同一验证码 code, err := app.otps.Current(context.Background(), "ext_lisi") if err != nil { t.Fatalf("otp current: %v", err) } // OTP 登录 w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "ext_lisi", "code": code}) if w.Code != http.StatusOK { t.Fatalf("otp login status = %d, body=%s", w.Code, w.Body.String()) } userCk := sessionCookie(t, w) // 外部用户 me w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk) if w.Code != http.StatusOK { t.Fatalf("user me status = %d", w.Code) } if m := decodeBody(t, w); m["data"].(map[string]any)["user_type"] != "user" { t.Fatalf("user me body = %s", w.Body.String()) } // 外部用户访问 admin 路由应 403 w = app.doJSON(http.MethodGet, "/api/v1/users", nil, userCk) if w.Code != http.StatusForbidden { t.Fatalf("user access admin status = %d, want 403", w.Code) } // 登出 w = app.doJSON(http.MethodPost, "/api/v1/auth/logout", nil, userCk) if w.Code != http.StatusOK { t.Fatalf("logout status = %d", w.Code) } w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk) if w.Code != http.StatusUnauthorized { t.Fatalf("me after logout status = %d, want 401", w.Code) } } func TestAPIAdminForgotReset(t *testing.T) { app := setupTestApp(t) w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/forgot", map[string]string{"username": "root"}) if w.Code != http.StatusOK { t.Fatalf("forgot status = %d", w.Code) } if app.mailer.lastTo != "root@example.com" { t.Fatalf("reset mail to = %q", app.mailer.lastTo) } idx := bytes.Index([]byte(app.mailer.lastBody), []byte("token=")) if idx < 0 { t.Fatalf("reset link missing token: %s", app.mailer.lastBody) } token := app.mailer.lastBody[idx+6:] token = token[:bytes.IndexByte([]byte(token), '\n')] w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/reset", map[string]string{"token": token, "new_password": "NewPassw0rd"}) if w.Code != http.StatusOK { t.Fatalf("reset status = %d, body=%s", w.Code, w.Body.String()) } // 新密码可登录 w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "NewPassw0rd"}) if w.Code != http.StatusOK { t.Fatalf("login with new password status = %d", w.Code) } } func itoa(u uint) string { return strconv.FormatUint(uint64(u), 10) }