Files
usernode/internal/api/api_test.go
T
cao.wangrenbo 630d240dc0 feat(M1): 认证与用户管理 — 双通道登录、cookie 会话、用户 CRUD 与真实系统账号对接
认证:
- 图形验证码 GET /auth/captcha(内置 PNG 渲染,零第三方依赖)
- 外部用户 OTP 双通道:DB 存储(otp_codes)使邮件与 CLI 共用同一验证码/冷却/失败限速
- 管理员 bcrypt 登录 + 连续失败限速锁定;admin/forgot + admin/reset 邮件重置(SMTP 或日志)
- cookie 会话(HttpOnly/SameSite)、me/logout、admin/user 鉴权中间件

用户管理(admin):
- CRUD + disable/enable/extend/delete,对接 system 层真实 useradd/usermod/userdel/passwd
- system 层三执行模式:dry-run(默认,安全)/ direct(容器/测试用户)/ sudo(生产 sudoers 白名单)
- Exists 系统账号一致性检查;deploy/sudoers.example 白名单模板
- 关键操作接入 append-only 审计

其他:
- CLI user otp 改 DB store,与邮件通道真正对齐
- 容器镜像补 shadow(alpine 无 useradd);Makefile VERSION 0.2.0-m1
- 测试:auth/service 单测 + api httptest 集成 + 容器内真实系统账号端到端验证
2026-08-29 23:40:20 +08:00

327 lines
9.9 KiB
Go

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)
}