Files
ttstd-infinite-canvas/backend/internal/auth/password_reset_test.go

307 lines
11 KiB
Go

package auth
import (
"encoding/json"
"errors"
"infinite-canvas/backend/internal/kernel"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T) {
svc, db := newPasswordResetTestService(t)
oldHash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
// Existing users can reset passwords even outside the registration whitelist.
user := model.User{ID: "user-1", Username: "creator", Email: "creator@external.example", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
sessions := []model.AuthSession{
{ID: "session-1", UserID: user.ID, TokenHash: "one", ExpiresAt: time.Now().Add(time.Hour)},
{ID: "session-2", UserID: user.ID, TokenHash: "two", ExpiresAt: time.Now().Add(time.Hour)},
}
if err := db.Create(&sessions).Error; err != nil {
t.Fatal(err)
}
var deliveredCode string
svc.SetMailSender(func(_ EmailSettingValue, recipient string, subject string, body string) error {
if recipient != user.Email || subject != "影策密码重置验证码" {
t.Fatalf("unexpected reset email: recipient=%q subject=%q", recipient, subject)
}
deliveredCode = codeFromEmailBody(body)
return nil
})
if err := svc.SendPasswordResetEmailCode(" Creator@Example.com "); err != nil {
t.Fatal(err)
}
if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
t.Fatalf("repeat send should keep the public response generic: %v", err)
}
if len(deliveredCode) != 6 {
t.Fatalf("delivered code = %q", deliveredCode)
}
var storedCode model.EmailVerificationCode
if err := db.Where("email = ? AND purpose = ?", user.Email, passwordResetEmailPurpose).First(&storedCode).Error; err != nil {
t.Fatal(err)
}
if storedCode.CodeHash == deliveredCode || storedCode.CodeHash == "" {
t.Fatalf("verification code was not hashed: %#v", storedCode)
}
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "new-password"}); err != nil {
t.Fatal(err)
}
var updated model.User
if err := db.First(&updated, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")) != nil {
t.Fatal("new password hash does not match")
}
if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("old-password")) == nil {
t.Fatal("old password still matches")
}
var sessionCount int64
if err := db.Model(&model.AuthSession{}).Where("user_id = ?", user.ID).Count(&sessionCount).Error; err != nil {
t.Fatal(err)
}
if sessionCount != 0 {
t.Fatalf("auth session count = %d, want 0", sessionCount)
}
if err := db.First(&storedCode, "id = ?", storedCode.ID).Error; err != nil {
t.Fatal(err)
}
if storedCode.UsedAt == nil {
t.Fatal("password reset code was not consumed")
}
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "another-password"}); !isInvalidPasswordResetError(err) {
t.Fatalf("reused code error = %v", err)
}
}
func TestPasswordResetSendDoesNotRevealAccountEligibility(t *testing.T) {
svc, db := newPasswordResetTestService(t)
hash, err := HashPassword("strong-password")
if err != nil {
t.Fatal(err)
}
users := []model.User{
{ID: "oauth-user", Username: "oauth-user", Email: "oauth@example.com", Status: model.UserStatusActive},
{ID: "disabled-user", Username: "disabled-user", Email: "disabled@example.com", Status: model.UserStatusDisabled, PasswordHash: hash},
}
if err := db.Create(&users).Error; err != nil {
t.Fatal(err)
}
deliveries := 0
svc.SetMailSender(func(_ EmailSettingValue, _ string, _ string, _ string) error {
deliveries++
return nil
})
for _, email := range []string{"missing@example.com", "oauth@example.com", "disabled@example.com"} {
if err := svc.SendPasswordResetEmailCode(email); err != nil {
t.Fatalf("SendPasswordResetEmailCode(%q) error = %v", email, err)
}
}
if deliveries != 0 {
t.Fatalf("mail deliveries = %d, want 0", deliveries)
}
var codeCount int64
if err := db.Model(&model.EmailVerificationCode{}).Count(&codeCount).Error; err != nil {
t.Fatal(err)
}
if codeCount != 0 {
t.Fatalf("verification code count = %d, want 0", codeCount)
}
}
func TestPasswordResetRejectsWrongExpiredAndPurposeMismatchedCodes(t *testing.T) {
svc, db := newPasswordResetTestService(t)
hash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
resetHash, err := svc.emailVerificationCodeHash(passwordResetEmailPurpose, user.Email, "123456")
if err != nil {
t.Fatal(err)
}
registrationHash, err := svc.emailVerificationCodeHash(registrationEmailPurpose, user.Email, "123456")
if err != nil {
t.Fatal(err)
}
if resetHash == registrationHash {
t.Fatal("email verification hashes must be separated by purpose")
}
record := model.EmailVerificationCode{ID: "expired", Email: user.Email, Purpose: passwordResetEmailPurpose, CodeHash: resetHash, ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-11 * time.Minute)}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "123456", Password: "new-password"}); !isInvalidPasswordResetError(err) {
t.Fatalf("expired code error = %v", err)
}
record.ID = "active"
record.ExpiresAt = time.Now().Add(time.Minute)
record.CreatedAt = time.Now()
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "654321", Password: "new-password"}); !isInvalidPasswordResetError(err) {
t.Fatalf("wrong code error = %v", err)
}
}
func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) {
svc, db := newPasswordResetTestService(t)
hash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
svc.SetMailSender(func(_ EmailSettingValue, _ string, _ string, _ string) error { return errors.New("smtp unavailable") })
if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
t.Fatalf("delivery failure should keep the public response generic: %v", err)
}
var count int64
if err := db.Model(&model.EmailVerificationCode{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("verification code count = %d, want 0", count)
}
}
func TestSenderNameBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
for _, tc := range []struct {
name string
fromName string
wantName string
}{
{name: "default inherits appearance", fromName: DefaultBrandName, wantName: "HIMA Studio"},
{name: "empty inherits appearance", fromName: "", wantName: "HIMA Studio"},
{name: "custom sender overrides appearance", fromName: "Custom Canvas", wantName: "Custom Canvas"},
{name: "custom sender is trimmed", fromName: " 自定义工作台 ", wantName: "自定义工作台"},
} {
t.Run(tc.name, func(t *testing.T) {
assertRegistrationAndPasswordResetEmailBrand(t, tc.fromName, tc.wantName)
})
}
}
func assertRegistrationAndPasswordResetEmailBrand(t *testing.T, fromName, wantName string) {
t.Helper()
svc, db := newPasswordResetTestService(t)
_, emailSetting, err := svc.readEmailSetting()
if err != nil {
t.Fatal(err)
}
emailSetting.FromName = fromName
settingJSON, err := json.Marshal(emailSetting)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", string(settingJSON)).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil {
t.Fatal(err)
}
svc.host = brandHost{name: "HIMA Studio"}
passwordHash, err := HashPassword("strong-password")
if err != nil {
t.Fatal(err)
}
existing := model.User{ID: "brand-admin", Username: "brand-admin", Email: "admin@example.com", Role: model.UserRoleAdmin, Status: model.UserStatusActive, PasswordHash: passwordHash}
resetUser := model.User{ID: "brand-user", Username: "brand-user", Email: "member@example.com", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: passwordHash}
if err := db.Create(&[]model.User{existing, resetUser}).Error; err != nil {
t.Fatal(err)
}
type delivery struct {
fromName string
recipient string
subject string
body string
}
deliveries := make([]delivery, 0, 2)
svc.SetMailSender(func(setting EmailSettingValue, recipient string, subject string, body string) error {
deliveries = append(deliveries, delivery{fromName: setting.FromName, recipient: recipient, subject: subject, body: body})
return nil
})
if err := svc.SendRegistrationEmailCode("new-member@example.com"); err != nil {
t.Fatal(err)
}
if err := svc.SendPasswordResetEmailCode(resetUser.Email); err != nil {
t.Fatal(err)
}
if len(deliveries) != 2 {
t.Fatalf("deliveries = %d, want 2", len(deliveries))
}
for _, delivered := range deliveries {
if delivered.fromName != wantName || !strings.Contains(delivered.subject, wantName) || !strings.Contains(delivered.body, wantName) || strings.Contains(delivered.subject, DefaultBrandName) || strings.Contains(delivered.body, DefaultBrandName) {
t.Fatalf("email did not use resolved sender name %q: %#v", wantName, delivered)
}
}
if deliveries[0].subject != wantName+"注册验证码" || deliveries[1].subject != wantName+"密码重置验证码" {
t.Fatalf("unexpected branded subjects: %#v", deliveries)
}
if !strings.Contains(deliveries[0].body, "你正在注册"+wantName+"。") {
t.Fatalf("registration body did not use resolved sender name: %q", deliveries[0].body)
}
}
func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil {
t.Fatal(err)
}
settingJSON, err := json.Marshal(EmailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}})
if err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SystemSetting{Key: emailSettingKey, ValueJSON: string(settingJSON)}).Error; err != nil {
t.Fatal(err)
}
return New(repository.New(db), nil, nil), db
}
func codeFromEmailBody(body string) string {
marker := "验证码:"
start := strings.Index(body, marker)
if start < 0 {
return ""
}
value := body[start+len(marker):]
if end := strings.IndexByte(value, '\n'); end >= 0 {
value = value[:end]
}
return strings.TrimSpace(value)
}
func isInvalidPasswordResetError(err error) bool {
var appErr *kernel.AppError
return errors.As(err, &appErr) && appErr.Status == 400 && appErr.Message == "验证码无效或已过期"
}