feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
Delegates all credential management (login, register, password reset, email verification, social sign-in) to Ory Kratos. The Go API now acts as a resource server: the new KratosAuth middleware validates sessions against the Kratos whoami endpoint, writes the local User mirror into Echo context, and all existing domain handlers continue working unchanged. Hand-rolled token auth, AuthToken model, apple_auth/ google_auth services, and the auth refresh flow are removed. Tests are updated to use the fake-token middleware pattern so existing integration assertions require no rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ func setupModelsTestDB(t *testing.T) *gorm.DB {
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = db.AutoMigrate(&User{}, &AuthToken{}, &UserProfile{})
|
||||
err = db.AutoMigrate(&User{}, &UserProfile{})
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
@@ -233,105 +233,6 @@ func TestNotificationType_Constants(t *testing.T) {
|
||||
assert.Equal(t, NotificationType("warranty_expiring"), NotificationWarrantyExpiring)
|
||||
}
|
||||
|
||||
// === AuthToken model tests ===
|
||||
|
||||
func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "tokenuser",
|
||||
Email: "token@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token := &AuthToken{UserID: user.ID}
|
||||
err = db.Create(token).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, token.Key)
|
||||
assert.Len(t, token.Key, 64) // SHA-256 hex hash (audit C1)
|
||||
assert.Len(t, token.Plaintext, 40) // raw 20-byte token, returned to the client
|
||||
assert.False(t, token.Created.IsZero())
|
||||
}
|
||||
|
||||
func TestAuthToken_BeforeCreate_PreservesExistingKey(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "tokenuser",
|
||||
Email: "token@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
existingKey := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
token := &AuthToken{
|
||||
Key: existingKey,
|
||||
UserID: user.ID,
|
||||
}
|
||||
err = db.Create(token).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, existingKey, token.Key)
|
||||
}
|
||||
|
||||
func TestGetOrCreateToken_CreatesNew(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "newtoken",
|
||||
Email: "newtoken@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token.Key)
|
||||
assert.Equal(t, user.ID, token.UserID)
|
||||
}
|
||||
|
||||
func TestGetOrCreateToken_ReturnsExisting(t *testing.T) {
|
||||
db := setupModelsTestDB(t)
|
||||
|
||||
user := &User{
|
||||
Username: "existingtoken",
|
||||
Email: "existingtoken@test.com",
|
||||
Password: "dummy",
|
||||
IsActive: true,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
token1, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
token2, err := GetOrCreateToken(db, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, token1.Key, token2.Key)
|
||||
}
|
||||
|
||||
// === User model additional tests ===
|
||||
|
||||
func TestUser_SetPassword_And_CheckPassword_Integration(t *testing.T) {
|
||||
user := &User{}
|
||||
err := user.SetPassword("Password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, user.CheckPassword("Password123"))
|
||||
assert.False(t, user.CheckPassword("WrongPassword"))
|
||||
assert.False(t, user.CheckPassword(""))
|
||||
assert.False(t, user.CheckPassword("password123")) // case sensitive
|
||||
}
|
||||
|
||||
// === Task model additional tests ===
|
||||
|
||||
func TestTask_IsOverdue_CancelledNotOverdue(t *testing.T) {
|
||||
@@ -565,31 +466,6 @@ func TestGetDefaultProLimits(t *testing.T) {
|
||||
assert.Nil(t, limits.DocumentsLimit)
|
||||
}
|
||||
|
||||
// === ConfirmationCode additional tests ===
|
||||
|
||||
func TestConfirmationCode_TableName(t *testing.T) {
|
||||
cc := ConfirmationCode{}
|
||||
assert.Equal(t, "user_confirmationcode", cc.TableName())
|
||||
}
|
||||
|
||||
// === PasswordResetCode additional tests ===
|
||||
|
||||
func TestPasswordResetCode_TableName(t *testing.T) {
|
||||
prc := PasswordResetCode{}
|
||||
assert.Equal(t, "user_passwordresetcode", prc.TableName())
|
||||
}
|
||||
|
||||
// === Social Auth TableName tests ===
|
||||
|
||||
func TestAppleSocialAuth_TableName(t *testing.T) {
|
||||
a := AppleSocialAuth{}
|
||||
assert.Equal(t, "user_applesocialauth", a.TableName())
|
||||
}
|
||||
|
||||
func TestGoogleSocialAuth_TableName(t *testing.T) {
|
||||
g := GoogleSocialAuth{}
|
||||
assert.Equal(t, "user_googlesocialauth", g.TableName())
|
||||
}
|
||||
|
||||
// === BaseModel tests ===
|
||||
|
||||
|
||||
+26
-263
@@ -1,69 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
import "time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents the auth_user table (Django's default User model)
|
||||
// User represents the auth_user table. Identity — credentials, email
|
||||
// verification, sessions, social sign-in — is owned by Ory Kratos (phase 2).
|
||||
// This row is honeyDue's local mirror of a Kratos identity, linked by
|
||||
// KratosID; every domain table keeps its existing integer FK to auth_user.id.
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Password string `gorm:"column:password;size:128;not null" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
KratosID string `gorm:"column:kratos_id;uniqueIndex;size:36" json:"-"` // Kratos identity UUID
|
||||
Username string `gorm:"column:username;uniqueIndex;size:150" json:"username"`
|
||||
FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
|
||||
LastName string `gorm:"column:last_name;size:150" json:"last_name"`
|
||||
Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"`
|
||||
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
||||
DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"date_joined"`
|
||||
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"`
|
||||
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
||||
Username string `gorm:"column:username;uniqueIndex;size:150;not null" json:"username"`
|
||||
FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
|
||||
LastName string `gorm:"column:last_name;size:150" json:"last_name"`
|
||||
Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"`
|
||||
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
|
||||
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
||||
DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"date_joined"`
|
||||
|
||||
// Relations (not stored in auth_user table)
|
||||
Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
|
||||
AuthToken *AuthToken `gorm:"foreignKey:UserID" json:"-"`
|
||||
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
|
||||
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
|
||||
// Relations — not columns on auth_user.
|
||||
Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
|
||||
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
|
||||
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
|
||||
NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"`
|
||||
Subscription *UserSubscription `gorm:"foreignKey:UserID" json:"-"`
|
||||
Subscription *UserSubscription `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
// TableName returns the table name for GORM.
|
||||
func (User) TableName() string {
|
||||
return "auth_user"
|
||||
}
|
||||
|
||||
// BcryptCost is the bcrypt work factor for password and code hashing.
|
||||
// 12 (audit M2) is stronger than bcrypt.DefaultCost (10).
|
||||
const BcryptCost = 12
|
||||
|
||||
// SetPassword hashes and sets the password
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Django uses PBKDF2_SHA256 by default, but we use bcrypt for Go.
|
||||
// Passwords set by Django won't verify with Go's bcrypt check — those
|
||||
// users must reset their password after migration.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against the stored hash
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullName returns the user's full name
|
||||
// GetFullName returns the user's display name.
|
||||
func (u *User) GetFullName() string {
|
||||
if u.FirstName != "" && u.LastName != "" {
|
||||
return u.FirstName + " " + u.LastName
|
||||
@@ -74,80 +43,9 @@ func (u *User) GetFullName() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// AuthToken represents the user_authtoken table.
|
||||
//
|
||||
// Audit C1: the Key column stores the SHA-256 hash of the token, never the
|
||||
// token itself. The raw token is handed to the client exactly once, at
|
||||
// creation, via the non-persisted Plaintext field — it is never stored or
|
||||
// logged. A database compromise therefore yields no usable session tokens.
|
||||
type AuthToken struct {
|
||||
Key string `gorm:"column:key;primaryKey;size:64" json:"-"`
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
|
||||
|
||||
// Plaintext is the raw token value. It is never persisted (gorm:"-")
|
||||
// and is only populated on a freshly-created token so the caller can
|
||||
// return it to the client. On a token loaded from the DB it is "".
|
||||
Plaintext string `gorm:"-" json:"-"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (AuthToken) TableName() string {
|
||||
return "user_authtoken"
|
||||
}
|
||||
|
||||
// BeforeCreate generates a token if one is not already set, storing only
|
||||
// its hash in Key and the raw value in the non-persisted Plaintext field.
|
||||
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.Key == "" {
|
||||
raw := generateToken()
|
||||
t.Plaintext = raw
|
||||
t.Key = HashToken(raw)
|
||||
}
|
||||
if t.Created.IsZero() {
|
||||
t.Created = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateToken creates a random 40-character hex token (the raw value).
|
||||
func generateToken() string {
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashToken returns the at-rest representation of an auth token: the
|
||||
// hex-encoded SHA-256 hash. Auth tokens are 160-bit random values, so a
|
||||
// fast deterministic hash is appropriate — there is nothing to brute-force,
|
||||
// and determinism preserves the single indexed-lookup query in the auth
|
||||
// middleware. The raw token is never stored.
|
||||
func HashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GetOrCreate gets an existing token or creates a new one for the user
|
||||
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
|
||||
var token AuthToken
|
||||
result := tx.Where("user_id = ?", userID).First(&token)
|
||||
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
token = AuthToken{UserID: userID}
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// UserProfile represents the user_userprofile table
|
||||
// UserProfile represents the user_userprofile table — honeyDue-specific
|
||||
// profile data, keyed to a local user. Email-verification state is owned by
|
||||
// Kratos; the Verified column is a convenience mirror set at provision time.
|
||||
type UserProfile struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
||||
@@ -161,142 +59,7 @@ type UserProfile struct {
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
// TableName returns the table name for GORM.
|
||||
func (UserProfile) TableName() string {
|
||||
return "user_userprofile"
|
||||
}
|
||||
|
||||
// ConfirmationCode represents the user_confirmationcode table
|
||||
type ConfirmationCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
Code string `gorm:"column:code;size:6;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
IsUsed bool `gorm:"column:is_used;default:false" json:"is_used"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (ConfirmationCode) TableName() string {
|
||||
return "user_confirmationcode"
|
||||
}
|
||||
|
||||
// IsValid checks if the confirmation code is still valid
|
||||
func (c *ConfirmationCode) IsValid() bool {
|
||||
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// GenerateConfirmationCode creates a uniformly-random 6-digit code using
|
||||
// rejection sampling on crypto/rand (audit H4 — removes the modulo bias of
|
||||
// the previous implementation).
|
||||
func GenerateConfirmationCode() string {
|
||||
for {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
continue
|
||||
}
|
||||
// 4294000000 is the largest multiple of 1e6 <= MaxUint32; rejecting
|
||||
// the tail above it makes n % 1000000 perfectly uniform.
|
||||
n := binary.BigEndian.Uint32(b[:])
|
||||
if n < 4294000000 {
|
||||
return fmt.Sprintf("%06d", n%1000000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PasswordResetCode represents the user_passwordresetcode table
|
||||
type PasswordResetCode struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
||||
CodeHash string `gorm:"column:code_hash;size:128;not null" json:"-"`
|
||||
ResetToken string `gorm:"column:reset_token;uniqueIndex;size:64;not null" json:"reset_token"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
||||
Used bool `gorm:"column:used;default:false" json:"used"`
|
||||
Attempts int `gorm:"column:attempts;default:0" json:"attempts"`
|
||||
MaxAttempts int `gorm:"column:max_attempts;default:5" json:"max_attempts"`
|
||||
|
||||
// Relations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "user_passwordresetcode"
|
||||
}
|
||||
|
||||
// SetCode hashes and stores the reset code
|
||||
func (p *PasswordResetCode) SetCode(code string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(code), BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.CodeHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCode verifies a code against the stored hash
|
||||
func (p *PasswordResetCode) CheckCode(code string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(p.CodeHash), []byte(code))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsValid checks if the reset code is still valid
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.Used && time.Now().UTC().Before(p.ExpiresAt) && p.Attempts < p.MaxAttempts
|
||||
}
|
||||
|
||||
// IncrementAttempts increments the attempt counter
|
||||
func (p *PasswordResetCode) IncrementAttempts(tx *gorm.DB) error {
|
||||
p.Attempts++
|
||||
return tx.Model(p).Update("attempts", p.Attempts).Error
|
||||
}
|
||||
|
||||
// MarkAsUsed marks the code as used
|
||||
func (p *PasswordResetCode) MarkAsUsed(tx *gorm.DB) error {
|
||||
p.Used = true
|
||||
return tx.Model(p).Update("used", true).Error
|
||||
}
|
||||
|
||||
// GenerateResetToken creates a URL-safe token
|
||||
func GenerateResetToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// AppleSocialAuth represents a user's linked Apple ID for Sign in with Apple
|
||||
type AppleSocialAuth struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
AppleID string `gorm:"column:apple_id;size:255;uniqueIndex;not null" json:"apple_id"` // Apple's unique subject ID
|
||||
Email string `gorm:"column:email;size:254" json:"email"` // May be private relay
|
||||
IsPrivateEmail bool `gorm:"column:is_private_email;default:false" json:"is_private_email"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (AppleSocialAuth) TableName() string {
|
||||
return "user_applesocialauth"
|
||||
}
|
||||
|
||||
// GoogleSocialAuth represents a user's linked Google account for Sign in with Google
|
||||
type GoogleSocialAuth struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
GoogleID string `gorm:"column:google_id;size:255;uniqueIndex;not null" json:"google_id"` // Google's unique subject ID
|
||||
Email string `gorm:"column:email;size:254" json:"email"`
|
||||
Name string `gorm:"column:name;size:255" json:"name"`
|
||||
Picture string `gorm:"column:picture;size:512" json:"picture"` // Profile picture URL
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (GoogleSocialAuth) TableName() string {
|
||||
return "user_googlesocialauth"
|
||||
}
|
||||
|
||||
@@ -2,50 +2,15 @@ package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUser_SetPassword(t *testing.T) {
|
||||
user := &User{}
|
||||
|
||||
err := user.SetPassword("testPassword123")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, user.Password)
|
||||
assert.NotEqual(t, "testPassword123", user.Password) // Should be hashed
|
||||
}
|
||||
|
||||
func TestUser_CheckPassword(t *testing.T) {
|
||||
user := &User{}
|
||||
err := user.SetPassword("correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expected bool
|
||||
}{
|
||||
{"correct password", "correctpassword", true},
|
||||
{"wrong password", "wrongpassword", false},
|
||||
{"empty password", "", false},
|
||||
{"similar password", "correctpassword1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := user.CheckPassword(tt.password)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_GetFullName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user User
|
||||
expected string
|
||||
name string
|
||||
user User
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "first and last name",
|
||||
@@ -82,136 +47,7 @@ func TestUser_TableName(t *testing.T) {
|
||||
assert.Equal(t, "auth_user", user.TableName())
|
||||
}
|
||||
|
||||
func TestAuthToken_TableName(t *testing.T) {
|
||||
token := AuthToken{}
|
||||
assert.Equal(t, "user_authtoken", token.TableName())
|
||||
}
|
||||
|
||||
func TestUserProfile_TableName(t *testing.T) {
|
||||
profile := UserProfile{}
|
||||
assert.Equal(t, "user_userprofile", profile.TableName())
|
||||
}
|
||||
|
||||
func TestConfirmationCode_IsValid(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(1 * time.Hour)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ConfirmationCode
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid code",
|
||||
code: ConfirmationCode{IsUsed: false, ExpiresAt: future},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "used code",
|
||||
code: ConfirmationCode{IsUsed: true, ExpiresAt: future},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired code",
|
||||
code: ConfirmationCode{IsUsed: false, ExpiresAt: past},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "used and expired",
|
||||
code: ConfirmationCode{IsUsed: true, ExpiresAt: past},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.code.IsValid()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetCode_IsValid(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
future := now.Add(1 * time.Hour)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code PasswordResetCode
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid code",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "used code",
|
||||
code: PasswordResetCode{Used: true, ExpiresAt: future, Attempts: 0, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired code",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: past, Attempts: 0, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "max attempts reached",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 5, MaxAttempts: 5},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "attempts under max",
|
||||
code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 4, MaxAttempts: 5},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.code.IsValid()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetCode_SetAndCheckCode(t *testing.T) {
|
||||
code := &PasswordResetCode{}
|
||||
|
||||
err := code.SetCode("123456")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, code.CodeHash)
|
||||
|
||||
// Check correct code
|
||||
assert.True(t, code.CheckCode("123456"))
|
||||
|
||||
// Check wrong code
|
||||
assert.False(t, code.CheckCode("654321"))
|
||||
assert.False(t, code.CheckCode(""))
|
||||
}
|
||||
|
||||
func TestGenerateConfirmationCode(t *testing.T) {
|
||||
code := GenerateConfirmationCode()
|
||||
assert.Len(t, code, 6)
|
||||
|
||||
// Generate multiple codes and ensure they're different
|
||||
codes := make(map[string]bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
c := GenerateConfirmationCode()
|
||||
assert.Len(t, c, 6)
|
||||
codes[c] = true
|
||||
}
|
||||
// Most codes should be unique (very unlikely to have collisions)
|
||||
assert.Greater(t, len(codes), 5)
|
||||
}
|
||||
|
||||
func TestGenerateResetToken(t *testing.T) {
|
||||
token := GenerateResetToken()
|
||||
assert.Len(t, token, 64) // 32 bytes = 64 hex chars
|
||||
|
||||
// Ensure uniqueness
|
||||
token2 := GenerateResetToken()
|
||||
assert.NotEqual(t, token, token2)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user