Files
kevin 1fec77e81f 新增个人中心页面与用户资料自助修改接口
- 新增 GET/PUT /api/me:登录用户可查看并修改自己的 nickname/gender/birthday,空串清空、缺省保持不变,生日校验格式与未来日期
- users 表新增 gender/birthday 字段(迁移 v6)及 model.Date 日期类型,管理员用户接口同步支持
- 前端新增 /profile 个人中心页面、头像下拉入口与登录守卫,保存后同步会话用户信息
- 静态服务对未命中的无扩展名路径回退 index.html,避免刷新前端路由 404
- 重新生成 Swagger 文档,补充 /api/me 与资料字段相关测试
2026-09-21 16:05:23 +08:00

353 lines
13 KiB
Go

package auth_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"rill/internal/auth"
"rill/internal/model"
"rill/internal/testutil"
)
func registerViaAPI(t *testing.T, r http.Handler, username, email, password string, extra map[string]any) model.User {
t.Helper()
body := map[string]any{"username": username, "email": email, "password": password}
for k, v := range extra {
body[k] = v
}
w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", body)
if w.Code != http.StatusCreated {
t.Fatalf("注册状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
}
return testutil.DecodeUser(t, w)
}
func decodeLogin(t *testing.T, w *httptest.ResponseRecorder) auth.LoginResponse {
t.Helper()
var resp auth.LoginResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String())
}
return resp
}
func TestRegister(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
// 即使显式传 group_ids 也不能提权,公开注册固定进入普通用户组。
user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", map[string]any{"group_ids": []uint{model.GroupIDAdmin}})
if user.ID == 0 || user.Status != 1 {
t.Fatalf("注册结果异常: %+v", user)
}
if len(user.Groups) != 1 || user.Groups[0].ID != model.GroupIDUser {
t.Fatalf("注册用户组异常: %+v", user.Groups)
}
if user.Gender != "" || !user.Birthday.IsZero() {
t.Errorf("注册默认资料字段应为空: gender=%q birthday=%v", user.Gender, user.Birthday.Time)
}
w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{
"username": "alice2", "email": "alice2@example.com", "password": "secret123",
})
if strings.Contains(w.Body.String(), "password") {
t.Errorf("注册响应泄露密码字段: %s", w.Body.String())
}
var stored model.User
if err := env.DB.First(&stored, user.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.PasswordHash == "secret123" {
t.Fatal("密码未加密存储")
}
if err := bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte("secret123")); err != nil {
t.Fatalf("密码哈希校验失败: %v", err)
}
w = testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{
"username": "alice", "email": "other@example.com", "password": "secret123",
})
if w.Code != http.StatusConflict {
t.Errorf("重复用户名状态码 = %d, 期望 %d", w.Code, http.StatusConflict)
}
w = testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{
"username": "alice3", "email": "alice@example.com", "password": "secret123",
})
if w.Code != http.StatusConflict {
t.Errorf("重复邮箱状态码 = %d, 期望 %d", w.Code, http.StatusConflict)
}
}
func TestRegisterValidation(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
cases := []struct {
name string
body map[string]string
}{
{"用户名过短", map[string]string{"username": "ab", "email": "a@example.com", "password": "secret123"}},
{"缺少邮箱", map[string]string{"username": "alice", "password": "secret123"}},
{"邮箱格式非法", map[string]string{"username": "alice", "email": "not-email", "password": "secret123"}},
{"密码过短", map[string]string{"username": "alice", "email": "a@example.com", "password": "12345"}},
}
for _, tc := range cases {
w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
}
func TestLogin(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "alice", "password": "secret123",
})
if w.Code != http.StatusOK {
t.Fatalf("用户名登录状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
resp := decodeLogin(t, w)
if resp.Token == "" || resp.User.ID != user.ID {
t.Fatalf("登录响应异常: %+v", resp)
}
if !resp.ExpiresAt.After(time.Now()) {
t.Errorf("expires_at 应晚于当前时间: %v", resp.ExpiresAt)
}
if len(resp.User.Groups) != 1 || resp.User.Groups[0].ID != model.GroupIDUser {
t.Errorf("登录响应用户组异常: %+v", resp.User.Groups)
}
w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "alice@example.com", "password": "secret123",
})
if w.Code != http.StatusOK {
t.Fatalf("邮箱登录状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
emailResp := decodeLogin(t, w)
authed := env.Router(emailResp.Token)
if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusOK {
t.Errorf("登录凭证访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
if w := testutil.Call(t, authed, http.MethodGet, "/api/users", nil); w.Code != http.StatusForbidden {
t.Errorf("普通用户访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "alice", "password": "wrong-password",
})
if w.Code != http.StatusUnauthorized {
t.Errorf("密码错误状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
if !strings.Contains(w.Body.String(), "incorrect account or password") {
t.Errorf("错误文案应为英文: %s", w.Body.String())
}
w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "nobody", "password": "secret123",
})
if w.Code != http.StatusUnauthorized {
t.Errorf("账号不存在状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
}
func TestLoginDisabledUser(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
if err := env.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("status", 0).Error; err != nil {
t.Fatalf("禁用用户失败: %v", err)
}
w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "alice", "password": "secret123",
})
if w.Code != http.StatusForbidden {
t.Fatalf("禁用账号登录状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
authed := env.Router(env.Sign(user.ID))
if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusForbidden {
t.Errorf("禁用账号访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
}
func TestLoginSoftDeletedUser(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
if err := env.DB.Delete(&model.User{}, user.ID).Error; err != nil {
t.Fatalf("删除用户失败: %v", err)
}
w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{
"account": "alice", "password": "secret123",
})
if w.Code != http.StatusUnauthorized {
t.Fatalf("已删除账号登录状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
authed := env.Router(env.Sign(user.ID))
if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized {
t.Errorf("已删除账号访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
}
func TestAuthMiddleware(t *testing.T) {
env := testutil.Setup(t)
if w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/health", nil); w.Code != http.StatusOK {
t.Errorf("health 应公开访问, 状态码 = %d", w.Code)
}
anonymous := env.Router("")
if w := testutil.Call(t, anonymous, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized {
t.Errorf("匿名访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
invalid := env.Router("not-a-token")
if w := testutil.Call(t, invalid, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized {
t.Errorf("非法凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
expiredCfg := *env.Cfg
expiredCfg.Auth.TokenTTL = "1ns"
expiredAuthn := auth.NewAuthenticator(&expiredCfg)
expiredToken, _, err := expiredAuthn.Sign(env.Admin.ID)
if err != nil {
t.Fatalf("签发过期凭证失败: %v", err)
}
expired := env.Router(expiredToken)
if w := testutil.Call(t, expired, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized {
t.Errorf("过期凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
wrongCfg := *env.Cfg
wrongCfg.Auth.Secret = "other-secret"
wrongAuthn := auth.NewAuthenticator(&wrongCfg)
wrongToken, _, err := wrongAuthn.Sign(env.Admin.ID)
if err != nil {
t.Fatalf("签发错误密钥凭证失败: %v", err)
}
wrong := env.Router(wrongToken)
if w := testutil.Call(t, wrong, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized {
t.Errorf("错误签名凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
if w := testutil.Call(t, env.AdminRouter(), http.MethodGet, "/api/users", nil); w.Code != http.StatusOK {
t.Errorf("管理员访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
}
func TestProfile(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
registered := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
authed := env.Router(env.Sign(registered.ID))
if w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/me", nil); w.Code != http.StatusUnauthorized {
t.Errorf("匿名获取个人资料状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
w := testutil.Call(t, authed, http.MethodGet, "/api/me", nil)
if w.Code != http.StatusOK {
t.Fatalf("获取个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
me := testutil.DecodeUser(t, w)
if me.ID != registered.ID || me.Username != "alice" || me.Email != "alice@example.com" {
t.Fatalf("个人资料异常: %+v", me)
}
if len(me.Groups) != 1 || me.Groups[0].ID != model.GroupIDUser {
t.Errorf("个人资料用户组异常: %+v", me.Groups)
}
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"nickname": "Alice",
"gender": model.GenderMale,
"birthday": "1995-06-15",
})
if w.Code != http.StatusOK {
t.Fatalf("更新个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := testutil.DecodeUser(t, w)
if updated.Nickname != "Alice" || updated.Gender != model.GenderMale {
t.Errorf("更新结果异常: %+v", updated)
}
if updated.Birthday.IsZero() || updated.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("更新生日异常: %v", updated.Birthday.Time)
}
var stored model.User
if err := env.DB.First(&stored, registered.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.Nickname != "Alice" || stored.Gender != model.GenderMale || stored.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("数据库未更新: %+v", stored)
}
// 只传部分字段时其余字段保持不变,附带的管理员字段不得生效。
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"gender": model.GenderFemale,
"status": 0,
"group_ids": []uint{model.GroupIDAdmin},
})
if w.Code != http.StatusOK {
t.Fatalf("部分更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
partial := testutil.DecodeUser(t, w)
if partial.Gender != model.GenderFemale || partial.Nickname != "Alice" {
t.Errorf("部分更新结果异常: %+v", partial)
}
if partial.Birthday.IsZero() || partial.Birthday.Format("2006-01-02") != "1995-06-15" {
t.Errorf("未提供 birthday 时不应修改: %v", partial.Birthday.Time)
}
if partial.Status != 1 || len(partial.Groups) != 1 || partial.Groups[0].ID != model.GroupIDUser {
t.Errorf("普通用户不得修改状态或用户组: %+v", partial)
}
cases := []struct {
name string
body map[string]any
}{
{"非法性别", map[string]any{"gender": "unknown"}},
{"生日格式非法", map[string]any{"birthday": "15-06-1995"}},
{"生日在未来", map[string]any{"birthday": time.Now().AddDate(1, 0, 0).Format("2006-01-02")}},
{"昵称超长", map[string]any{"nickname": strings.Repeat("a", 51)}},
}
for _, tc := range cases {
w := testutil.Call(t, authed, http.MethodPut, "/api/me", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{"nickname": "", "gender": "", "birthday": ""})
if w.Code != http.StatusOK {
t.Fatalf("清空个人资料状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
if cleared := testutil.DecodeUser(t, w); cleared.Nickname != "" || cleared.Gender != "" || !cleared.Birthday.IsZero() {
t.Errorf("清空失败: %+v", cleared)
}
var clearedStored model.User
if err := env.DB.First(&clearedStored, registered.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if clearedStored.Nickname != "" || clearedStored.Gender != "" || !clearedStored.Birthday.IsZero() {
t.Errorf("数据库未清空: %+v", clearedStored)
}
}