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