Files
ttstd-infinite-canvas/backend/internal/repository/account_test.go
2026-10-13 05:31:30 +08:00

244 lines
8.1 KiB
Go

package repository
import (
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"yingce/backend/internal/model"
)
func profileRepositoryDB(t *testing.T) (*gorm.DB, string) {
t.Helper()
dsn := filepath.Join(t.TempDir(), "profile.db") + "?_busy_timeout=5000&_journal_mode=WAL"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AuthVerification{}, &model.UserIdentity{}); err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
return db, dsn
}
func TestProfilePasswordConcurrentChangeUsesCredentialCompare(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token-hash", ExpiresAt: now.Add(time.Hour)}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(session).Error; err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
var wg sync.WaitGroup
for _, newHash := range []string{"new-one", "new-two"} {
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
wg.Add(1)
go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfilePassword(u, session, newHash, now) }()
}
close(start)
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
} else if !errors.Is(err, ErrProfileAuthorization) {
t.Fatal(err)
}
}
if success != 1 {
t.Fatalf("successful changes=%d", success)
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 0 {
t.Fatal("session remained")
}
}
func TestLinuxDOReauthenticationPersistsAcrossSessionTimezones(t *testing.T) {
for _, offset := range []int{-12, 14} {
t.Run(time.FixedZone("session", offset*3600).String(), func(t *testing.T) {
db, _ := profileRepositoryDB(t)
now := time.Now().UTC()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour).In(time.FixedZone("session", offset*3600))}
identity := &model.UserIdentity{ID: "identity", UserID: u.ID, Provider: "linuxdo", Subject: "subject"}
for _, row := range []any{u, session, identity} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
if err := New(db).ReauthenticateLinuxDOSession(u, session, identity.Subject, now); err != nil {
t.Fatal(err)
}
var stored model.AuthSession
if err := db.First(&stored, "id = ?", session.ID).Error; err != nil {
t.Fatal(err)
}
if stored.ReauthenticatedAt == nil || !stored.ReauthenticatedAt.Equal(now) {
t.Fatal("successful reauthentication did not persist its timestamp")
}
})
}
}
func TestProfileEmailConcurrentCanonicalCollisionDoesNotConsumeLoser(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
start := make(chan struct{})
results := make(chan error, 2)
var wg sync.WaitGroup
for i, id := range []string{"one", "two"} {
u := &model.User{ID: id, Username: id, Status: model.UserStatusActive, PasswordHash: "hash"}
session := &model.AuthSession{ID: id, UserID: id, TokenHash: "token", ExpiresAt: now.Add(time.Hour)}
v := &model.AuthVerification{ID: id, UserID: id, SessionID: id, CredentialHash: ProfileCredentialHash("hash"), Purpose: "profile_email", Email: []string{"a.b+first@gmail.com", "ab+second@googlemail.com"}[i], Ready: true, ExpiresAt: now.Add(time.Hour)}
for _, row := range []any{u, session, v} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
wg.Add(1)
go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfileEmail(u, session, v, now) }()
}
close(start)
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
} else if err.Error() != "邮箱已被注册" {
t.Fatal(err)
}
}
if success != 1 {
t.Fatalf("successful changes=%d", success)
}
var count int64
db.Model(&model.AuthVerification{}).Where("used_at IS NOT NULL").Count(&count)
if count != 1 {
t.Fatalf("consumed=%d", count)
}
db.Model(&model.AuthSession{}).Count(&count)
if count != 1 {
t.Fatalf("remaining sessions=%d", count)
}
}
func TestProfileEmailRollbackWhenSessionRevocationFails(t *testing.T) {
db, _ := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Email: "old@example.com", PasswordHash: "hash", Status: model.UserStatusActive}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour)}
v := &model.AuthVerification{ID: "ticket", Purpose: "profile_email", UserID: u.ID, SessionID: session.ID, Email: "new@example.com", CredentialHash: ProfileCredentialHash("hash"), Ready: true, ExpiresAt: now.Add(time.Hour)}
for _, row := range []any{u, session, v} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec(`CREATE TRIGGER fail_revoke BEFORE DELETE ON auth_sessions BEGIN SELECT RAISE(ABORT, 'revocation failed'); END`).Error; err != nil {
t.Fatal(err)
}
if err := New(db).ChangeProfileEmail(u, session, v, now); err == nil {
t.Fatal("revocation failure ignored")
}
var stored model.User
if err := db.First(&stored, "id = ?", u.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Email != u.Email || stored.EmailVerifiedAt != nil {
t.Fatal("email update not rolled back")
}
var ticket model.AuthVerification
if err := db.First(&ticket, "id = ?", v.ID).Error; err != nil {
t.Fatal(err)
}
if ticket.UsedAt != nil {
t.Fatal("ticket consumption not rolled back")
}
}
func TestProfileCredentialChangeRejectsInFlightLoginSession(t *testing.T) {
db, _ := profileRepositoryDB(t)
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash", Email: "old@example.com"}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.User{}).Where("id = ?", u.ID).Update("password_hash", "new-hash").Error; err != nil {
t.Fatal(err)
}
session := &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "token", ExpiresAt: time.Now().Add(time.Hour)}
if err := New(db).CreateAuthSessionForUser(u, session); !errors.Is(err, ErrProfileAuthorization) {
t.Fatalf("stale login accepted: %v", err)
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 0 {
t.Fatal("stale login inserted session")
}
}
func TestProfilePasswordRevocationSerializesWithConcurrentSessionIssuance(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"}
oldSession := &model.AuthSession{ID: "old-session", UserID: u.ID, TokenHash: "old-token", ExpiresAt: now.Add(time.Hour)}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(oldSession).Error; err != nil {
t.Fatal(err)
}
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
start := make(chan struct{})
issued := make(chan error, 1)
changed := make(chan error, 1)
go func() {
<-start
issued <- New(peer).CreateAuthSessionForUser(u, &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "new-token", ExpiresAt: now.Add(time.Hour)})
}()
go func() { <-start; changed <- New(db).ChangeProfilePassword(u, oldSession, "new-hash", now) }()
close(start)
if err := <-changed; err != nil {
t.Fatal(err)
}
if err := <-issued; err != nil && !errors.Is(err, ErrProfileAuthorization) {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("sessions surviving concurrent revocation=%d", count)
}
}