244 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|