feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
220
backend/internal/handler/auth_profile_test.go
Normal file
220
backend/internal/handler/auth_profile_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"yingce/backend/internal/auth"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
"yingce/backend/internal/service"
|
||||
)
|
||||
|
||||
func profileHandlerFixture(t *testing.T) (*gin.Engine, *gorm.DB, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "fixture.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := auth.HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "profile-user", Username: "profile-user", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: hash}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]model.AuthSession{
|
||||
{ID: "profile-session", UserID: user.ID, TokenHash: auth.HashToken("fixture-token"), ExpiresAt: time.Now().Add(time.Hour)},
|
||||
{ID: "profile-other-session", UserID: user.ID, TokenHash: auth.HashToken("other-token"), ExpiresAt: time.Now().Add(time.Hour)},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := service.New(repository.New(db), dir)
|
||||
ConfigureRuntime(svc)
|
||||
t.Cleanup(func() { ConfigureRuntime(nil); svc.Close(); sqlDB, _ := db.DB(); _ = sqlDB.Close() })
|
||||
router := gin.New()
|
||||
RegisterAuthRoutes(router.Group("/api"), svc)
|
||||
return router, db, "profile-session.fixture-token"
|
||||
}
|
||||
|
||||
func profileRequest(router http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if cookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestProfileRoutesRejectAnonymousBeforeMutation(t *testing.T) {
|
||||
router, _, _ := profileHandlerFixture(t)
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{"GET", "/api/auth/profile"}, {"PUT", "/api/auth/profile/avatar"},
|
||||
{"DELETE", "/api/auth/profile/avatar"}, {"GET", "/api/auth/profile/avatar/profile-user"},
|
||||
{"POST", "/api/auth/profile/avatar/default"},
|
||||
{"POST", "/api/auth/profile/password"}, {"POST", "/api/auth/profile/email-code"},
|
||||
{"POST", "/api/auth/profile/email"}, {"GET", "/api/auth/profile/linuxdo/start"},
|
||||
} {
|
||||
rec := profileRequest(router, route.method, route.path, "{}", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s returned %d", route.method, route.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePasswordHTTPRevokesSessionsAndClearsCookie(t *testing.T) {
|
||||
router, db, cookie := profileHandlerFixture(t)
|
||||
wrong := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"wrong-password","newPassword":"new-password"}`, cookie)
|
||||
if wrong.Code != 401 {
|
||||
t.Fatalf("wrong password status %d: %s", wrong.Code, wrong.Body.String())
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.AuthSession{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("failed change revoked sessions: %d", count)
|
||||
}
|
||||
rec := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"old-password","newPassword":"new-password"}`, cookie)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("change status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cleared := false
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Fatal("successful change did not clear login cookie")
|
||||
}
|
||||
var user model.User
|
||||
db.First(&user, "id = ?", "profile-user")
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("new-password")) != nil {
|
||||
t.Fatal("new password not persisted")
|
||||
}
|
||||
db.Model(&model.AuthSession{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("sessions remain after change: %d", count)
|
||||
}
|
||||
if status := profileRequest(router, "GET", "/api/auth/profile", "", cookie).Code; status != 401 {
|
||||
t.Fatalf("old cookie still accepted: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePasswordRejectsOversizedUTF8WithoutRevokingSessions(t *testing.T) {
|
||||
router, db, cookie := profileHandlerFixture(t)
|
||||
body, err := json.Marshal(map[string]string{"currentPassword": "old-password", "newPassword": strings.Repeat("密", 25)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := profileRequest(router, "POST", "/api/auth/profile/password", string(body), cookie)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized UTF-8 password status %d, want 400", rec.Code)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("invalid password revoked sessions: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileDefaultAvatarSelectionPersistsAndRejectsUnknownPreset(t *testing.T) {
|
||||
router, db, cookie := profileHandlerFixture(t)
|
||||
invalid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"unsupported"}`, cookie)
|
||||
if invalid.Code != 400 {
|
||||
t.Fatalf("unknown preset status %d", invalid.Code)
|
||||
}
|
||||
valid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"cat"}`, cookie)
|
||||
if valid.Code != 200 {
|
||||
t.Fatalf("selection status %d: %s", valid.Code, valid.Body.String())
|
||||
}
|
||||
var user model.User
|
||||
if err := db.First(&user, "id = ?", "profile-user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.AvatarMode != "default" || user.AvatarPreset != "cat" || user.AvatarVersion != "" {
|
||||
t.Fatal("selected default was not persisted")
|
||||
}
|
||||
reloaded := profileRequest(router, "GET", "/api/auth/profile", "", cookie)
|
||||
if reloaded.Code != 200 || !strings.Contains(reloaded.Body.String(), `"avatarPreset":"cat"`) {
|
||||
t.Fatalf("reloaded profile lost selection: %d", reloaded.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAvatarHTTPPersistsServesAndRestoresDefault(t *testing.T) {
|
||||
router, db, cookie := profileHandlerFixture(t)
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
part, err := form.CreateFormFile("file", "avatar.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := png.Encode(part, image.NewNRGBA(image.Rect(0, 0, 40, 20))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := form.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("PUT", "/api/auth/profile/avatar", &body)
|
||||
req.Header.Set("Content-Type", form.FormDataContentType())
|
||||
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
User struct {
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(envelope.Data.User.AvatarURL, "/api/auth/profile/avatar/profile-user?v=") {
|
||||
t.Fatalf("missing versioned avatar URL: %s", envelope.Data.User.AvatarURL)
|
||||
}
|
||||
content := profileRequest(router, "GET", envelope.Data.User.AvatarURL, "", cookie)
|
||||
if content.Code != 200 || content.Header().Get("Content-Type") != "image/png" || content.Header().Get("Cache-Control") != "private, no-store" {
|
||||
t.Fatalf("avatar response %d %v", content.Code, content.Header())
|
||||
}
|
||||
if foreign := profileRequest(router, "GET", "/api/auth/profile/avatar/other-user", "", cookie); foreign.Code != 403 {
|
||||
t.Fatalf("foreign avatar status %d", foreign.Code)
|
||||
}
|
||||
reset := profileRequest(router, "DELETE", "/api/auth/profile/avatar", "", cookie)
|
||||
if reset.Code != 200 {
|
||||
t.Fatalf("reset avatar status %d: %s", reset.Code, reset.Body.String())
|
||||
}
|
||||
var user model.User
|
||||
db.First(&user, "id = ?", "profile-user")
|
||||
if user.AvatarMode != "default" || user.AvatarVersion != "" {
|
||||
t.Fatalf("default not persisted: %q", user.AvatarMode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user