Files
fm_be/internal/service/auth_service_test.go
2026-07-16 22:16:45 +07:00

5618 lines
203 KiB
Go

package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/url"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp/totp"
"wucher/internal/config"
"wucher/internal/domain/auth"
"wucher/internal/queue"
tzpkg "wucher/internal/shared/pkg/timezone"
"wucher/internal/shared/pkg/uuidv7"
)
type authEmailBrandingResolverMock struct {
branding AuthEmailBranding
}
func (m authEmailBrandingResolverMock) ResolveEmailBranding(context.Context) AuthEmailBranding {
return m.branding
}
type mockTokenStore struct {
store map[string][]byte
lastKey string
setErr error
getErr error
setCount int
setDelay time.Duration
}
func newMockTokenStore() *mockTokenStore {
return &mockTokenStore{store: map[string][]byte{}}
}
func (m *mockTokenStore) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
if m.setErr != nil {
return m.setErr
}
if m.setDelay > 0 {
time.Sleep(m.setDelay)
}
m.store[key] = value
m.lastKey = key
m.setCount++
return nil
}
func (m *mockTokenStore) SetIfNotExists(_ context.Context, key string, value []byte, _ time.Duration) (bool, error) {
if m.setErr != nil {
return false, m.setErr
}
if _, ok := m.store[key]; ok {
return false, nil
}
m.store[key] = value
m.lastKey = key
m.setCount++
return true, nil
}
func (m *mockTokenStore) Get(_ context.Context, key string) ([]byte, error) {
if m.getErr != nil {
return nil, m.getErr
}
v, ok := m.store[key]
if !ok {
return nil, nil
}
return v, nil
}
func (m *mockTokenStore) Delete(_ context.Context, key string) error {
delete(m.store, key)
return nil
}
type mockAuthRepo struct {
users map[string]*auth.User
usersByMail map[string]*auth.User
usersByUsername map[string]*auth.User
identities map[string]*auth.UserIdentity
totp map[string]*auth.UserTOTP
webauthnCredentials map[string][]auth.UserWebAuthnCredential
resetTokens map[string]*auth.PasswordResetToken
securityPINResetTokens map[string]*auth.SecurityPINResetToken
emailLoginOTPs map[string]*auth.UserEmailLoginOTP
lastResetToken *auth.PasswordResetToken
lastSecurityPINResetToken *auth.SecurityPINResetToken
emailOutboxMessages []*auth.EmailOutboxMessage
lastUpdateTimezoneUserID []byte
lastUpdateTimezone string
createErr error
getByIDErr error
getByMailErr error
updateTimezoneErr error
setPasswordErr error
markVerifiedErr error
createResetTokenErr error
invalidateResetTokensErr error
consumeResetErr error
createPINResetTokenErr error
invalidatePINResetTokensErr error
consumePINResetErr error
deleteTOTPErr error
deleteWebAuthnErr error
outboxErr error
msOAuthTokens map[string]*auth.UserMicrosoftOAuthToken
}
func newMockAuthRepo() *mockAuthRepo {
return &mockAuthRepo{
users: map[string]*auth.User{},
usersByMail: map[string]*auth.User{},
usersByUsername: map[string]*auth.User{},
identities: map[string]*auth.UserIdentity{},
totp: map[string]*auth.UserTOTP{},
webauthnCredentials: map[string][]auth.UserWebAuthnCredential{},
resetTokens: map[string]*auth.PasswordResetToken{},
securityPINResetTokens: map[string]*auth.SecurityPINResetToken{},
emailLoginOTPs: map[string]*auth.UserEmailLoginOTP{},
msOAuthTokens: map[string]*auth.UserMicrosoftOAuthToken{},
}
}
func (r *mockAuthRepo) CreateUser(_ context.Context, user *auth.User) error {
if user == nil {
return errors.New("nil user")
}
if r.createErr != nil {
return r.createErr
}
if !user.IsActive {
user.IsActive = true
}
key := string(user.ID)
r.users[key] = user
if user.Email != "" {
r.usersByMail[user.Email] = user
}
if user.Username != nil && strings.TrimSpace(*user.Username) != "" {
r.usersByUsername[strings.TrimSpace(*user.Username)] = user
}
return nil
}
func (r *mockAuthRepo) WithTransaction(ctx context.Context, fn func(repo auth.Repository, outbox auth.EmailOutboxWriter) error) error {
if fn == nil {
return nil
}
return fn(r, r)
}
func (r *mockAuthRepo) CreateEmailOutboxMessage(_ context.Context, message *auth.EmailOutboxMessage) error {
if r.outboxErr != nil {
return r.outboxErr
}
if message == nil {
return errors.New("nil outbox message")
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
r.emailOutboxMessages = append(r.emailOutboxMessages, &cp)
return nil
}
func (r *mockAuthRepo) GetUserByID(_ context.Context, id []byte) (*auth.User, error) {
if r.getByIDErr != nil {
return nil, r.getByIDErr
}
return r.users[string(id)], nil
}
func (r *mockAuthRepo) GetUserByEmail(_ context.Context, email string) (*auth.User, error) {
if r.getByMailErr != nil {
return nil, r.getByMailErr
}
return r.usersByMail[email], nil
}
func (r *mockAuthRepo) GetUserBySSOEmail(_ context.Context, ssoEmail string) (*auth.User, error) {
if r.getByMailErr != nil {
return nil, r.getByMailErr
}
return r.usersByMail[ssoEmail], nil
}
func (r *mockAuthRepo) GetUserByUsername(_ context.Context, username string) (*auth.User, error) {
if r.getByMailErr != nil {
return nil, r.getByMailErr
}
return r.usersByUsername[username], nil
}
func (r *mockAuthRepo) UpdateUserTimezone(_ context.Context, id []byte, timezoneName string) error {
if r.updateTimezoneErr != nil {
return r.updateTimezoneErr
}
u := r.users[string(id)]
if u == nil {
return errors.New("not found")
}
u.Timezone = timezoneName
r.lastUpdateTimezoneUserID = append([]byte(nil), id...)
r.lastUpdateTimezone = timezoneName
return nil
}
func (r *mockAuthRepo) SetUserPassword(_ context.Context, id []byte, passwordHash, salt []byte) error {
if r.setPasswordErr != nil {
return r.setPasswordErr
}
u := r.users[string(id)]
if u == nil {
return errors.New("not found")
}
u.PasswordHash = passwordHash
u.SaltValue = salt
return nil
}
func (r *mockAuthRepo) SetUserSecurityPIN(_ context.Context, id []byte, pinHash []byte, setAt time.Time) error {
u := r.users[string(id)]
if u == nil {
return errors.New("not found")
}
u.SecurityPINHash = append([]byte(nil), pinHash...)
ts := setAt
u.SecurityPINSetAt = &ts
return nil
}
func (r *mockAuthRepo) MarkEmailVerified(_ context.Context, id []byte) error {
if r.markVerifiedErr != nil {
return r.markVerifiedErr
}
u := r.users[string(id)]
if u == nil {
return errors.New("not found")
}
now := time.Now().UTC()
u.EmailVerifiedAt = &now
return nil
}
func (r *mockAuthRepo) CreatePasswordResetToken(_ context.Context, token *auth.PasswordResetToken) error {
if r.createResetTokenErr != nil {
return r.createResetTokenErr
}
if token == nil {
return errors.New("nil token")
}
cp := *token
if len(cp.ID) == 0 {
cp.ID = uuidv7.MustBytes()
}
r.resetTokens[string(cp.TokenHash)] = &cp
r.lastResetToken = &cp
return nil
}
func (r *mockAuthRepo) InvalidateActivePasswordResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
if r.invalidateResetTokensErr != nil {
return r.invalidateResetTokensErr
}
for _, rec := range r.resetTokens {
if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) {
ts := usedAt
rec.UsedAt = &ts
}
}
return nil
}
func (r *mockAuthRepo) ConsumePasswordResetTokenAndSetPassword(_ context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time) ([]byte, bool, error) {
if r.consumeResetErr != nil {
return nil, false, r.consumeResetErr
}
rec := r.resetTokens[string(tokenHash)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
return nil, false, nil
}
u := r.users[string(rec.UserID)]
if u == nil {
return nil, false, nil
}
if r.setPasswordErr != nil {
return nil, false, r.setPasswordErr
}
u.PasswordHash = passwordHash
u.SaltValue = salt
ts := usedAt
rec.UsedAt = &ts
for _, other := range r.resetTokens {
if string(other.UserID) == string(rec.UserID) && string(other.ID) != string(rec.ID) && other.UsedAt == nil && other.ExpiresAt.After(usedAt) {
otherTS := usedAt
other.UsedAt = &otherTS
}
}
return rec.UserID, true, nil
}
func (r *mockAuthRepo) CreateSecurityPINResetToken(_ context.Context, token *auth.SecurityPINResetToken) error {
if r.createPINResetTokenErr != nil {
return r.createPINResetTokenErr
}
if token == nil {
return errors.New("nil token")
}
cp := *token
if len(cp.ID) == 0 {
cp.ID = uuidv7.MustBytes()
}
r.securityPINResetTokens[string(cp.TokenHash)] = &cp
r.lastSecurityPINResetToken = &cp
return nil
}
func (r *mockAuthRepo) InvalidateActiveSecurityPINResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
if r.invalidatePINResetTokensErr != nil {
return r.invalidatePINResetTokensErr
}
for _, rec := range r.securityPINResetTokens {
if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) {
ts := usedAt
rec.UsedAt = &ts
}
}
return nil
}
func (r *mockAuthRepo) GetActiveSecurityPINResetTokenUser(_ context.Context, tokenHash []byte, now time.Time) (*auth.User, error) {
rec := r.securityPINResetTokens[string(tokenHash)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
return nil, nil
}
return r.users[string(rec.UserID)], nil
}
func (r *mockAuthRepo) ConsumeSecurityPINResetTokenAndSetPIN(_ context.Context, tokenHash, pinHash []byte, usedAt time.Time) ([]byte, bool, error) {
if r.consumePINResetErr != nil {
return nil, false, r.consumePINResetErr
}
rec := r.securityPINResetTokens[string(tokenHash)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
return nil, false, nil
}
u := r.users[string(rec.UserID)]
if u == nil {
return nil, false, nil
}
u.SecurityPINHash = append([]byte(nil), pinHash...)
tsSet := usedAt
u.SecurityPINSetAt = &tsSet
ts := usedAt
rec.UsedAt = &ts
for _, other := range r.securityPINResetTokens {
if string(other.UserID) == string(rec.UserID) && string(other.ID) != string(rec.ID) && other.UsedAt == nil && other.ExpiresAt.After(usedAt) {
otherTS := usedAt
other.UsedAt = &otherTS
}
}
return rec.UserID, true, nil
}
func (r *mockAuthRepo) UpsertIdentity(_ context.Context, identity *auth.UserIdentity) error {
key := identity.Provider + ":" + identity.ProviderSubject
r.identities[key] = identity
return nil
}
func (r *mockAuthRepo) CreateIdentity(_ context.Context, identity *auth.UserIdentity) error {
key := identity.Provider + ":" + identity.ProviderSubject
r.identities[key] = identity
return nil
}
func (r *mockAuthRepo) GetIdentityByProviderSubject(_ context.Context, provider, subject string) (*auth.UserIdentity, error) {
return r.identities[provider+":"+subject], nil
}
func (r *mockAuthRepo) GetIdentityByUserID(_ context.Context, userID []byte) (*auth.UserIdentity, error) {
for _, identity := range r.identities {
if bytes.Equal(identity.UserID, userID) {
return identity, nil
}
}
return nil, nil
}
func (r *mockAuthRepo) DeleteIdentityByUserIDProvider(_ context.Context, userID []byte, provider string) (bool, error) {
for key, identity := range r.identities {
if bytes.Equal(identity.UserID, userID) && identity.Provider == provider {
delete(r.identities, key)
return true, nil
}
}
return false, nil
}
func (r *mockAuthRepo) GetUserTOTP(_ context.Context, userID []byte) (*auth.UserTOTP, error) {
return r.totp[string(userID)], nil
}
func (r *mockAuthRepo) UpsertUserTOTP(_ context.Context, totpRec *auth.UserTOTP) error {
r.totp[string(totpRec.UserID)] = totpRec
return nil
}
func (r *mockAuthRepo) DisableUserTOTP(_ context.Context, userID []byte) error {
rec := r.totp[string(userID)]
if rec == nil {
return nil
}
rec.Enabled = false
return nil
}
func (r *mockAuthRepo) DeleteUserTOTPSetup(_ context.Context, userID []byte) error {
if r.deleteTOTPErr != nil {
return r.deleteTOTPErr
}
delete(r.totp, string(userID))
return nil
}
func (r *mockAuthRepo) UpdateUserTOTPLastUsed(_ context.Context, userID []byte) error {
rec := r.totp[string(userID)]
if rec == nil {
return nil
}
now := time.Now().UTC()
rec.LastUsedAt = &now
return nil
}
func (r *mockAuthRepo) GetActiveUserEmailLoginOTP(_ context.Context, userID []byte, now time.Time) (*auth.UserEmailLoginOTP, error) {
rec := r.emailLoginOTPs[string(userID)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
return nil, nil
}
return rec, nil
}
func (r *mockAuthRepo) CountUserEmailLoginOTPSince(_ context.Context, userID []byte, since time.Time) (int64, error) {
rec := r.emailLoginOTPs[string(userID)]
if rec == nil || rec.CreatedAt.Before(since) {
return 0, nil
}
return 1, nil
}
func (r *mockAuthRepo) CreateUserEmailLoginOTP(_ context.Context, otp *auth.UserEmailLoginOTP) error {
if otp == nil {
return errors.New("nil otp")
}
cp := *otp
if len(cp.ID) == 0 {
cp.ID = uuidv7.MustBytes()
}
if cp.CreatedAt.IsZero() {
cp.CreatedAt = time.Now().UTC()
}
r.emailLoginOTPs[string(cp.UserID)] = &cp
return nil
}
func (r *mockAuthRepo) InvalidateActiveUserEmailLoginOTPs(_ context.Context, userID []byte, usedAt time.Time) error {
rec := r.emailLoginOTPs[string(userID)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
return nil
}
ts := usedAt
rec.UsedAt = &ts
return nil
}
func (r *mockAuthRepo) ConsumeUserEmailLoginOTP(_ context.Context, userID, otpHash []byte, now time.Time) (bool, int, error) {
rec := r.emailLoginOTPs[string(userID)]
if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
return false, 0, nil
}
if bytes.Equal(rec.OTPHash, otpHash) {
ts := now
rec.UsedAt = &ts
return true, max(0, rec.MaxAttempts-rec.AttemptCount), nil
}
rec.AttemptCount++
left := max(0, rec.MaxAttempts-rec.AttemptCount)
if rec.AttemptCount >= rec.MaxAttempts {
ts := now
rec.UsedAt = &ts
}
return false, left, nil
}
func (r *mockAuthRepo) ListUserWebAuthnCredentials(_ context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) {
list := r.webauthnCredentials[string(userID)]
out := make([]auth.UserWebAuthnCredential, len(list))
copy(out, list)
return out, nil
}
func (r *mockAuthRepo) CreateUserWebAuthnCredential(_ context.Context, credential *auth.UserWebAuthnCredential) error {
if credential == nil {
return errors.New("nil credential")
}
cp := *credential
if len(cp.ID) == 0 {
cp.ID = uuidv7.MustBytes()
}
key := string(cp.UserID)
r.webauthnCredentials[key] = append(r.webauthnCredentials[key], cp)
return nil
}
func (r *mockAuthRepo) UpdateUserWebAuthnCredential(_ context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error {
key := string(userID)
list := r.webauthnCredentials[key]
for i := range list {
if bytes.Equal(list[i].CredentialID, credentialID) {
list[i].CredentialJSON = append([]byte(nil), credentialJSON...)
ts := usedAt
list[i].LastUsedAt = &ts
r.webauthnCredentials[key] = list
return nil
}
}
return nil
}
func (r *mockAuthRepo) DeleteAllUserWebAuthnCredentials(_ context.Context, userID []byte) error {
if r.deleteWebAuthnErr != nil {
return r.deleteWebAuthnErr
}
delete(r.webauthnCredentials, string(userID))
return nil
}
func (r *mockAuthRepo) UpsertMicrosoftOAuthToken(_ context.Context, token *auth.UserMicrosoftOAuthToken) error {
if token == nil {
return errors.New("nil microsoft oauth token")
}
key := string(token.UserID) + ":" + strings.ToLower(strings.TrimSpace(token.Provider))
cp := *token
cp.UserID = append([]byte(nil), token.UserID...)
cp.RefreshTokenEncrypted = append([]byte(nil), token.RefreshTokenEncrypted...)
cp.AccessTokenEncrypted = append([]byte(nil), token.AccessTokenEncrypted...)
cp.IDTokenEncrypted = append([]byte(nil), token.IDTokenEncrypted...)
r.msOAuthTokens[key] = &cp
return nil
}
func (r *mockAuthRepo) GetMicrosoftOAuthTokenByUserIDProvider(_ context.Context, userID []byte, provider string) (*auth.UserMicrosoftOAuthToken, error) {
key := string(userID) + ":" + strings.ToLower(strings.TrimSpace(provider))
rec := r.msOAuthTokens[key]
if rec == nil {
return nil, nil
}
cp := *rec
cp.UserID = append([]byte(nil), rec.UserID...)
cp.RefreshTokenEncrypted = append([]byte(nil), rec.RefreshTokenEncrypted...)
cp.AccessTokenEncrypted = append([]byte(nil), rec.AccessTokenEncrypted...)
cp.IDTokenEncrypted = append([]byte(nil), rec.IDTokenEncrypted...)
return &cp, nil
}
func (r *mockAuthRepo) DeleteMicrosoftOAuthTokenByUserIDProvider(_ context.Context, userID []byte, provider string) (bool, error) {
key := string(userID) + ":" + strings.ToLower(strings.TrimSpace(provider))
if _, ok := r.msOAuthTokens[key]; !ok {
return false, nil
}
delete(r.msOAuthTokens, key)
return true, nil
}
type mockRoleRepo struct {
rolesByName map[string]*auth.Role
rolesByID map[string]*auth.Role
hasRolePermissionRet bool
hasRolePermissionByRole map[string]bool
hasRolePermissionErr error
permissionByKey map[string]*auth.Permission
getPermissionByKeyErr error
updatePermissionErr error
listPermissionsByRoleIDRet []auth.Permission
listPermissionsByRole map[string][]auth.Permission
listPermissionsByRoleIDErr error
}
func newMockRoleRepo() *mockRoleRepo {
return &mockRoleRepo{
rolesByName: map[string]*auth.Role{},
rolesByID: map[string]*auth.Role{},
hasRolePermissionRet: true,
hasRolePermissionByRole: map[string]bool{},
permissionByKey: map[string]*auth.Permission{},
listPermissionsByRoleIDRet: []auth.Permission{},
listPermissionsByRole: map[string][]auth.Permission{},
}
}
func (r *mockRoleRepo) CreateRole(_ context.Context, role *auth.Role) error {
return nil
}
func (r *mockRoleRepo) UpdateRole(_ context.Context, role *auth.Role) error {
return nil
}
func (r *mockRoleRepo) DeleteRole(_ context.Context, id []byte) error {
return nil
}
func (r *mockRoleRepo) GetRoleByID(_ context.Context, id []byte) (*auth.Role, error) {
return r.rolesByID[string(id)], nil
}
func (r *mockRoleRepo) GetRoleByName(_ context.Context, name string) (*auth.Role, error) {
return r.rolesByName[name], nil
}
func (r *mockRoleRepo) ListRoles(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Role, int64, error) {
return nil, 0, nil
}
func (r *mockRoleRepo) ListPermissions(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Permission, int64, error) {
return nil, 0, nil
}
func (r *mockRoleRepo) ListPermissionsByRoleID(_ context.Context, roleID []byte) ([]auth.Permission, error) {
if r.listPermissionsByRoleIDErr != nil {
return nil, r.listPermissionsByRoleIDErr
}
if perms, ok := r.listPermissionsByRole[string(roleID)]; ok {
return perms, nil
}
return r.listPermissionsByRoleIDRet, nil
}
func (r *mockRoleRepo) HasRolePermission(_ context.Context, roleID []byte, _ string) (bool, error) {
if r.hasRolePermissionErr != nil {
return false, r.hasRolePermissionErr
}
if allowed, ok := r.hasRolePermissionByRole[string(roleID)]; ok {
return allowed, nil
}
return r.hasRolePermissionRet, nil
}
func (r *mockRoleRepo) GetPermissionByID(_ context.Context, _ []byte) (*auth.Permission, error) {
return nil, nil
}
func (r *mockRoleRepo) GetPermissionByKey(_ context.Context, key string) (*auth.Permission, error) {
if r.getPermissionByKeyErr != nil {
return nil, r.getPermissionByKeyErr
}
return r.permissionByKey[key], nil
}
func (r *mockRoleRepo) UpdatePermission(_ context.Context, _ *auth.Permission) error {
return r.updatePermissionErr
}
func (r *mockRoleRepo) AssignPermission(_ context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
return &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}, nil
}
func (r *mockRoleRepo) RemovePermission(_ context.Context, _ []byte, _ []byte) error { return nil }
type mockEmailQueue struct {
last *EmailJob
err error
}
func (q *mockEmailQueue) Enqueue(_ context.Context, job EmailJob) error {
if q.err != nil {
return q.err
}
normalized := job.Canonical()
normalized.Body = normalized.TextBody
q.last = &normalized
return nil
}
func (q *mockEmailQueue) Dequeue(_ context.Context) (*EmailJob, error) {
return nil, nil
}
func queueTestSerializer() queue.MessageSerializer {
return queue.NewJSONSerializer("v1", true, queue.QueueTypeStandard, "")
}
type errorProtector struct {
encryptErr error
decryptErr error
}
func (p *errorProtector) Encrypt(_ []byte) ([]byte, error) {
if p.encryptErr != nil {
return nil, p.encryptErr
}
return []byte("cipher"), nil
}
func (p *errorProtector) Decrypt(_ []byte) ([]byte, error) {
if p.decryptErr != nil {
return nil, p.decryptErr
}
return []byte("123456"), nil
}
func TestAuthService_RegisterAndLogin(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
EmailVerifyTTL: time.Hour,
EmailVerifyURL: "http://localhost/verify",
SSOStateTTL: time.Minute,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
if err != nil {
t.Fatalf("register failed: %v", err)
}
if user.RoleID == nil || string(user.RoleID) != string(adminID) {
t.Fatalf("expected role admin")
}
if user.Username == nil || *user.Username != "john.doe" {
t.Fatalf("expected username john.doe")
}
if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 {
t.Fatalf("password not hashed")
}
if user.Timezone != tzpkg.Default {
t.Fatalf("expected default timezone %q, got %q", tzpkg.Default, user.Timezone)
}
if !user.IsActive {
t.Fatalf("expected registered user to be active")
}
// not verified yet
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "Password123!"); err != ErrEmailNotVerified {
t.Fatalf("expected ErrEmailNotVerified")
}
// verify email
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
t.Fatalf("mark verified: %v", err)
}
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "Password123!"); err != nil {
t.Fatalf("login failed: %v", err)
}
}
func TestAuthService_Register_ErrorCases(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
PasswordPepper: "pepper",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.RegisterWithEmail(context.Background(), "", "john.doe", "John", "Doe", "", "Password123!", []byte{1}); err == nil {
t.Fatalf("expected error for empty email")
}
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "", []byte{1}); err == nil {
t.Fatalf("expected error for empty password")
}
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", " ", "John", "Doe", "", "Password123!", []byte{1}); err == nil {
t.Fatalf("expected error for empty username")
}
}
func TestAuthService_InviteUser(t *testing.T) {
t.Run("success normalizes email and stores invite token", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
InviteTTL: time.Minute,
SetPasswordURL: "http://localhost/set-password",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
roleID := uuidv7.MustBytes()
user, err := svc.InviteUser(context.Background(), " Invitee@Example.COM ", "invitee", "John", "Doe", "08123", roleID)
if err != nil {
t.Fatalf("invite failed: %v", err)
}
if user == nil {
t.Fatalf("expected user")
}
if user.Email != "invitee@example.com" {
t.Fatalf("expected normalized email, got %q", user.Email)
}
if user.Username == nil || *user.Username != "invitee" {
t.Fatalf("expected username invitee")
}
if len(user.PasswordHash) != 0 || len(user.SaltValue) != 0 {
t.Fatalf("invite should not set password hash/salt")
}
if string(user.RoleID) != string(roleID) {
t.Fatalf("unexpected role id")
}
if user.Timezone != tzpkg.Default {
t.Fatalf("expected default timezone %q, got %q", tzpkg.Default, user.Timezone)
}
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:invite:") {
t.Fatalf("expected invite token stored, got key=%q", tokens.lastKey)
}
if string(tokens.store[tokens.lastKey]) != string(user.ID) {
t.Fatalf("token payload should contain invited user id")
}
if repo.usersByMail["invitee@example.com"] == nil {
t.Fatalf("user should be persisted by normalized email")
}
})
t.Run("empty email", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.InviteUser(context.Background(), " ", "invitee", "John", "Doe", "", uuidv7.MustBytes()); err == nil {
t.Fatalf("expected error for empty email")
}
})
t.Run("get by email error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.getByMailErr = errors.New("lookup failed")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
if !errors.Is(err, repo.getByMailErr) {
t.Fatalf("expected get by email error, got %v", err)
}
})
t.Run("email already registered", func(t *testing.T) {
repo := newMockAuthRepo()
repo.usersByMail["invitee@example.com"] = &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.InviteUser(context.Background(), "INVITEE@EXAMPLE.COM", "invitee", "John", "Doe", "", uuidv7.MustBytes()); err == nil {
t.Fatalf("expected duplicate email error")
}
if len(repo.users) != 0 {
t.Fatalf("create user should not be called for duplicate email")
}
})
t.Run("username already registered", func(t *testing.T) {
repo := newMockAuthRepo()
username := "invitee"
repo.usersByUsername[username] = &auth.User{ID: uuidv7.MustBytes(), Username: &username}
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.InviteUser(context.Background(), "invitee@example.com", username, "John", "Doe", "", uuidv7.MustBytes()); err == nil {
t.Fatalf("expected duplicate username error")
}
})
t.Run("create user error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.createErr = errors.New("db error")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
if !errors.Is(err, repo.createErr) {
t.Fatalf("expected create user error, got %v", err)
}
if tokens.lastKey != "" {
t.Fatalf("invite token should not be stored when create fails")
}
})
t.Run("invite token storage error", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.setErr = errors.New("token store error")
cfg := config.AuthConfig{
InviteTTL: time.Minute,
SetPasswordURL: "http://localhost/set-password",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
_, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", uuidv7.MustBytes())
if !errors.Is(err, tokens.setErr) {
t.Fatalf("expected token store error, got %v", err)
}
if repo.usersByMail["invitee@example.com"] != nil {
t.Fatalf("user should not be created when invite token generation fails")
}
})
}
func TestAuthService_UpdateUserTimezone(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
Timezone: tzpkg.Default,
}
repo.users[string(user.ID)] = user
repo.usersByMail[user.Email] = user
t.Run("success normalizes timezone", func(t *testing.T) {
got, err := svc.UpdateUserTimezone(context.Background(), user.ID, "Asia/Jakarta")
if err != nil {
t.Fatalf("UpdateUserTimezone failed: %v", err)
}
if got != "Asia/Jakarta" {
t.Fatalf("unexpected normalized timezone: %q", got)
}
if user.Timezone != "Asia/Jakarta" {
t.Fatalf("expected timezone updated on user, got %q", user.Timezone)
}
})
t.Run("reject invalid timezone", func(t *testing.T) {
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, "UTC+7")
if !errors.Is(err, tzpkg.ErrInvalid) {
t.Fatalf("expected invalid timezone error, got %v", err)
}
})
t.Run("reject empty timezone", func(t *testing.T) {
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, " ")
if !errors.Is(err, tzpkg.ErrEmpty) {
t.Fatalf("expected empty timezone error, got %v", err)
}
})
t.Run("reject empty user id", func(t *testing.T) {
_, err := svc.UpdateUserTimezone(context.Background(), nil, "Asia/Jakarta")
if err == nil {
t.Fatalf("expected error for empty user id")
}
})
t.Run("repo update timezone error", func(t *testing.T) {
repo.updateTimezoneErr = errors.New("update failed")
_, err := svc.UpdateUserTimezone(context.Background(), user.ID, "Asia/Jakarta")
if !errors.Is(err, repo.updateTimezoneErr) {
t.Fatalf("expected update timezone error, got %v", err)
}
})
}
func TestAuthService_SetPasswordWithInviteToken(t *testing.T) {
baseCfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
}
t.Run("success sets password verifies email and consumes token", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
repo.users[string(user.ID)] = user
token := "invite-token"
key := inviteKey(token)
tokens.store[key] = user.ID
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); err != nil {
t.Fatalf("set password failed: %v", err)
}
if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 {
t.Fatalf("expected password hash and salt to be stored")
}
if user.EmailVerifiedAt == nil {
t.Fatalf("expected invited user email to be marked verified")
}
if _, ok := tokens.store[key]; ok {
t.Fatalf("invite token should be deleted after success")
}
})
t.Run("token and password are required", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.SetPasswordWithInviteToken(context.Background(), "", "Password123!"); err == nil {
t.Fatalf("expected error for empty token")
}
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", ""); err == nil {
t.Fatalf("expected error for empty password")
}
})
t.Run("token store not configured", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
t.Fatalf("expected ErrInvalidToken, got %v", err)
}
})
t.Run("invalid invite token from store error", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.getErr = errors.New("token store get failed")
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
t.Fatalf("expected ErrInvalidToken, got %v", err)
}
})
t.Run("invalid invite token payload length", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.store[inviteKey("token")] = []byte("short")
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err != ErrInvalidToken {
t.Fatalf("expected ErrInvalidToken, got %v", err)
}
})
t.Run("set password repo error", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
tokens.store[inviteKey("token")] = uuidv7.MustBytes()
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); err == nil {
t.Fatalf("expected set password error")
}
})
t.Run("mark email verified error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.markVerifiedErr = errors.New("mark verify failed")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
repo.users[string(user.ID)] = user
key := inviteKey("token")
tokens.store[key] = user.ID
if err := svc.SetPasswordWithInviteToken(context.Background(), "token", "Password123!"); !errors.Is(err, repo.markVerifiedErr) {
t.Fatalf("expected mark verify error, got %v", err)
}
if _, ok := tokens.store[key]; !ok {
t.Fatalf("token should remain when mark email verified fails")
}
})
t.Run("jwt invite success", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := baseCfg
cfg.JWTAccessSecret = "access"
cfg.InviteTTL = time.Minute
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
repo.users[string(user.ID)] = user
repo.usersByMail[user.Email] = user
token, jti, err := svc.issueInviteJWT(user)
if err != nil {
t.Fatalf("issue invite jwt: %v", err)
}
tokens.store[inviteKey(jti)] = user.ID
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); err != nil {
t.Fatalf("set password with jwt invite failed: %v", err)
}
if len(user.PasswordHash) == 0 || user.EmailVerifiedAt == nil {
t.Fatalf("expected password and email verification updated")
}
if _, ok := tokens.store[inviteKey(jti)]; ok {
t.Fatalf("jwt invite token should be deleted after success")
}
})
t.Run("jwt invite token store mismatch", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := baseCfg
cfg.JWTAccessSecret = "access"
cfg.InviteTTL = time.Minute
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "invitee@example.com"}
repo.users[string(user.ID)] = user
token, jti, err := svc.issueInviteJWT(user)
if err != nil {
t.Fatalf("issue invite jwt: %v", err)
}
tokens.store[inviteKey(jti)] = uuidv7.MustBytes()
if err := svc.SetPasswordWithInviteToken(context.Background(), token, "Password123!"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token on jwt store mismatch, got %v", err)
}
})
}
func TestAuthServiceDurationGetters(t *testing.T) {
cfg := config.AuthConfig{
JWTRefreshTOTPTTL: 2 * time.Hour,
SecurityPINActionTokenTTL: 15 * time.Minute,
}
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if svc.RefreshTOTPTTL() != 2*time.Hour {
t.Fatalf("expected RefreshTOTPTTL getter to return configured ttl")
}
if svc.SecurityPINTokenTTL() != 15*time.Minute {
t.Fatalf("expected SecurityPINTokenTTL getter to return configured ttl")
}
}
func TestAuthService_GetRoleIDByName_NotFound(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetRoleIDByName(context.Background(), "missing"); err == nil {
t.Fatalf("expected error when role not found")
}
}
func TestAuthService_Login_InvalidPassword(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
roleID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
repo.MarkEmailVerified(context.Background(), user.ID)
if _, err := svc.LoginWithEmailPassword(context.Background(), "user@example.com", "WrongPass"); err != ErrInvalidCredentials {
t.Fatalf("expected invalid credentials")
}
}
func TestAuthService_IssueTokens_ConfigMissing(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
if _, err := svc.IssueTokens(context.Background(), user); err == nil {
t.Fatalf("expected error without jwt secrets")
}
}
func TestAuthService_Register_CreateUserError(t *testing.T) {
repo := newMockAuthRepo()
repo.createErr = errors.New("db error")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID); err == nil {
t.Fatalf("expected create user error")
}
}
func TestAuthService_Login_UserNotFound(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.LoginWithEmailPassword(context.Background(), "missing@example.com", "pass"); err != ErrInvalidCredentials {
t.Fatalf("expected invalid credentials")
}
}
func TestAuthService_Login_InactiveUser(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "inactive@example.com",
FirstName: "Jane",
LastName: "Doe",
RoleID: uuidv7.MustBytes(),
IsActive: true,
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
hash, salt, err := svc.hashPassword("Password123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user.PasswordHash = hash
user.SaltValue = salt
now := time.Now().UTC()
user.EmailVerifiedAt = &now
user.IsActive = false
if _, err := svc.LoginWithEmailPassword(context.Background(), user.Email, "Password123!"); err != ErrInvalidCredentials {
t.Fatalf("expected invalid credentials for inactive user, got %v", err)
}
}
func TestAuthService_ParseAccessToken_Invalid(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{JWTAccessSecret: "access"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.ParseAccessToken(context.Background(), "bad"); err == nil {
t.Fatalf("expected invalid token error")
}
}
func TestAuthService_ParseAccessToken_WrongType(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, _ := svc.IssueTokens(context.Background(), user)
if _, err := svc.ParseAccessToken(context.Background(), pair.RefreshToken); err == nil {
t.Fatalf("expected invalid token type")
}
}
func TestAuthService_ParseAccessToken_Empty(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{JWTAccessSecret: "access"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.ParseAccessToken(context.Background(), ""); err == nil {
t.Fatalf("expected error for empty token")
}
}
func TestAuthService_CreateConsumeTOTPChallenge_Invalid(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.ConsumeTOTPChallenge(context.Background(), "missing"); err == nil {
t.Fatalf("expected invalid challenge")
}
}
func TestAuthService_CreateTOTPChallenge_TokenStoreError(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.setErr = errors.New("store error")
cfg := config.AuthConfig{TOTPChallengeTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.CreateTOTPChallenge(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected token store error")
}
}
func TestAuthService_IsTOTPEnabled_NoRecord(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
enabled, err := svc.IsTOTPEnabled(context.Background(), uuidv7.MustBytes())
if err != nil || enabled {
t.Fatalf("expected disabled without error")
}
}
func TestAuthService_VerifyEmail_InvalidToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.VerifyEmail(context.Background(), "bad"); err == nil {
t.Fatalf("expected invalid token")
}
}
func TestAuthService_VerifyEmail_NoTokenStore(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
cfg := config.AuthConfig{}
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if err := svc.VerifyEmail(context.Background(), "token"); err == nil {
t.Fatalf("expected invalid token without store")
}
}
func TestAuthService_VerifyEmail_EmptyToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.VerifyEmail(context.Background(), ""); err == nil {
t.Fatalf("expected invalid token")
}
}
func TestAuthService_SSOConfigMissing(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
t.Fatalf("expected sso not configured")
}
}
func TestAuthService_BeginMicrosoftLogin_NoTokenStore(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
t.Fatalf("expected error without token store")
}
}
func TestAuthService_CompleteMicrosoftLogin_SSOServerError(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{err: errors.New("exchange failed")}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
state := "state1"
_ = tokens.Set(context.Background(), "auth:sso_state:"+state, []byte("1"), time.Minute)
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state); err == nil {
t.Fatalf("expected exchange error")
}
}
func TestAuthService_CompleteMicrosoftLogin_ConsumesStateOnFailure(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
state := "state1"
_ = tokens.Set(context.Background(), "auth:sso_state:"+state, []byte("1"), time.Minute)
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state); !errors.Is(err, ErrSSONotLinked) {
t.Fatalf("expected ErrSSONotLinked, got %v", err)
}
if _, ok := tokens.store["auth:sso_state:"+state]; ok {
t.Fatalf("expected state token to be consumed on failure")
}
}
func TestAuthService_LoginWithMicrosoft_EmailNotFound(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
if _, _, err := svc.LoginWithMicrosoft(context.Background(), "code"); !errors.Is(err, ErrSSONotLinked) {
t.Fatalf("expected ErrSSONotLinked, got %v", err)
}
}
func TestAuthService_BeginMicrosoftLogin_TokenStoreError(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.setErr = errors.New("store error")
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
if _, err := svc.BeginMicrosoftLogin(context.Background()); err == nil {
t.Fatalf("expected token store error")
}
}
func TestAuthService_BeginMicrosoftSessionCheck(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{authURL: "http://example.com?state=%s&prompt=select_account"}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
urlStr, err := svc.BeginMicrosoftSessionCheck(context.Background())
if err != nil {
t.Fatalf("begin microsoft session check: %v", err)
}
if !strings.Contains(urlStr, "prompt=none") {
t.Fatalf("expected prompt=none in url, got %s", urlStr)
}
if !strings.HasPrefix(tokens.lastKey, "auth:sso_state:") {
t.Fatalf("expected sso state stored, got key %s", tokens.lastKey)
}
state := strings.TrimPrefix(tokens.lastKey, "auth:sso_state:")
if state == "" {
t.Fatalf("expected state in key")
}
mode, err := svc.MicrosoftStateMode(context.Background(), state)
if err != nil {
t.Fatalf("microsoft state mode: %v", err)
}
if mode != "login_check" {
t.Fatalf("expected login_check mode, got %s", mode)
}
}
func TestAuthService_BeginMicrosoftLogin_UsesPromptSelectAccount(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{authURL: "http://example.com?state=%s&prompt=select_account"}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
urlStr, err := svc.BeginMicrosoftLogin(context.Background())
if err != nil {
t.Fatalf("begin microsoft login: %v", err)
}
if !strings.Contains(urlStr, "prompt=select_account") {
t.Fatalf("expected prompt=select_account in url, got %s", urlStr)
}
}
func TestAuthService_CompleteMicrosoftLogin_StateNotFound(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", "missing"); !errors.Is(err, ErrInvalidSSOState) {
t.Fatalf("expected ErrInvalidSSOState, got %v", err)
}
}
func TestAuthService_CompleteMicrosoftLogin_InvalidState(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "u@e.com", Name: "Jane"}}
cfg := config.AuthConfig{SSOStateTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
if _, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", ""); !errors.Is(err, ErrInvalidSSOState) {
t.Fatalf("expected ErrInvalidSSOState, got %v", err)
}
}
func TestAuthService_LoginWithMicrosoft_ExistingIdentity(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
roleID := uuidv7.MustBytes()
existing := &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: uuidv7.MustBytes(),
Provider: "microsoft",
ProviderSubject: "sub",
Email: "user@example.com",
}
if err := repo.CreateUser(context.Background(), &auth.User{
ID: existing.UserID,
Email: "user@example.com",
RoleID: roleID,
Timezone: tzpkg.WithDefault(""),
}); err != nil {
t.Fatalf("create user: %v", err)
}
repo.UpsertIdentity(context.Background(), existing)
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
identity, _, err := svc.LoginWithMicrosoft(context.Background(), "code")
if err != nil || identity == nil {
t.Fatalf("expected existing identity")
}
if string(identity.UserID) != string(existing.UserID) {
t.Fatalf("should return existing identity")
}
}
func TestAuthService_LoginWithMicrosoft_MatchesUserByEmailAndCreatesIdentity(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
roleID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
if err := repo.CreateUser(context.Background(), &auth.User{
ID: userID,
Email: "marco@micro.com",
RoleID: roleID,
Timezone: tzpkg.WithDefault(""),
}); err != nil {
t.Fatalf("create user: %v", err)
}
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-new", Email: "marco@micro.com", Name: "Marco"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
identity, _, err := svc.LoginWithMicrosoft(context.Background(), "code")
if err != nil || identity == nil {
t.Fatalf("expected identity, got err=%v", err)
}
if string(identity.UserID) != string(userID) {
t.Fatalf("expected login matched by email to existing user")
}
saved, _ := repo.GetIdentityByProviderSubject(context.Background(), "microsoft", "sub-new")
if saved == nil || string(saved.UserID) != string(userID) {
t.Fatalf("expected identity to be upserted")
}
}
func TestAuthService_LoginWithMicrosoft_RejectsSubjectLinkedToAnotherUser(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
roleID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
otherUserID := uuidv7.MustBytes()
_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "marco@micro.com", RoleID: roleID, Timezone: tzpkg.WithDefault("")})
_ = repo.CreateUser(context.Background(), &auth.User{ID: otherUserID, Email: "other@micro.com", RoleID: roleID, Timezone: tzpkg.WithDefault("")})
_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: otherUserID,
Provider: "microsoft",
ProviderSubject: "sub-conflict",
Email: "other@micro.com",
})
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-conflict", Email: "marco@micro.com", Name: "Marco"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
if _, _, err := svc.LoginWithMicrosoft(context.Background(), "code"); !errors.Is(err, ErrSSOLinkedToAnotherUser) {
t.Fatalf("expected ErrSSOLinkedToAnotherUser, got %v", err)
}
}
func TestAuthService_SSORedirectGetter(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{SSOSuccessRedirectURL: "http://localhost/cb"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if svc.SSOSuccessRedirectURL() == "" {
t.Fatalf("expected redirect url")
}
}
func TestAuthService_MicrosoftLogoutURL(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{SSOSuccessRedirectURL: "http://localhost/after-logout"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: &mockSSO{}, Protector: nil, Config: cfg})
got := svc.MicrosoftLogoutURL()
if !strings.Contains(got, "http://example.com/logout") || !strings.Contains(got, "post_logout_redirect_uri=") {
t.Fatalf("unexpected microsoft logout url: %q", got)
}
}
func TestAuthService_ConfirmTOTP_InvalidCode(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, _ := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
cfg := config.AuthConfig{TOTPIssuer: "Wucher"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
userID := uuidv7.MustBytes()
_, _, _, _ = svc.SetupTOTP(context.Background(), userID, "user@example.com")
ok, err := svc.ConfirmTOTP(context.Background(), userID, "000000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Fatalf("expected confirm false")
}
}
func TestAuthService_ConfirmTOTP_NoRecord(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if ok, err := svc.ConfirmTOTP(context.Background(), uuidv7.MustBytes(), "123456"); err == nil || ok {
t.Fatalf("expected error when record missing")
}
}
func TestAuthService_SetupTOTP_NoProtector(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, _, _, err := svc.SetupTOTP(context.Background(), uuidv7.MustBytes(), "user@example.com"); err == nil {
t.Fatalf("expected error without protector")
}
}
func TestAuthService_EmailQueuePath(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
queue := &mockEmailQueue{}
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
EmailVerifyURL: "http://localhost/verify",
EmailVerifyTTL: time.Minute,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
_, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
if err != nil {
t.Fatalf("register failed: %v", err)
}
if queue.last == nil {
t.Fatalf("expected email queued")
}
}
func TestAuthService_RegisterWithEmail_OutboxPath(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
queue := &mockEmailQueue{err: errors.New("queue failed")}
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
EmailVerifyURL: "http://localhost/verify",
EmailVerifyTTL: time.Minute,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
svc.SetEmailOutbox(repo, queueTestSerializer())
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
if err != nil {
t.Fatalf("register failed: %v", err)
}
if user == nil {
t.Fatalf("expected user")
}
if queue.last != nil {
t.Fatalf("direct queue should not be used when outbox is configured")
}
if len(repo.emailOutboxMessages) != 1 {
t.Fatalf("expected one outbox message, got %d", len(repo.emailOutboxMessages))
}
}
func TestAuthService_LegacyConstructorWithoutQueue(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
EmailVerifyURL: "http://localhost/verify",
EmailVerifyTTL: time.Minute,
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
if err != nil {
t.Fatalf("register failed: %v", err)
}
if user == nil {
t.Fatalf("expected registered user")
}
}
func TestAuthService_GetRoleByID_NoRepo(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetRoleByID(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected error when role repo nil")
}
}
func TestAuthService_HasPermission(t *testing.T) {
t.Run("auth repo not configured", func(t *testing.T) {
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(nil, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read"); err == nil || allowed {
t.Fatalf("expected auth repo not configured error")
}
})
t.Run("role repo not configured", func(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read"); err == nil || allowed {
t.Fatalf("expected role repo not configured error")
}
})
t.Run("get user error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.getByIDErr = errors.New("db error")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
_, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read")
if !errors.Is(err, repo.getByIDErr) {
t.Fatalf("expected get user error, got %v", err)
}
})
t.Run("user not found", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
roles.hasRolePermissionErr = errors.New("should not be called")
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
allowed, err := svc.HasPermission(context.Background(), uuidv7.MustBytes(), "users.read")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if allowed {
t.Fatalf("expected no permission when user not found")
}
})
t.Run("delegates to role repo", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
roleID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
roles := newMockRoleRepo()
roles.hasRolePermissionRet = true
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
allowed, err := svc.HasPermission(context.Background(), userID, "users.read")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !allowed {
t.Fatalf("expected permission allowed")
}
})
t.Run("role repo returns error", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
roleID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID}
roles := newMockRoleRepo()
roles.hasRolePermissionErr = errors.New("role repo error")
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.HasPermission(context.Background(), userID, "users.read"); !errors.Is(err, roles.hasRolePermissionErr) {
t.Fatalf("expected role repo error, got %v", err)
}
})
t.Run("allows permission from additional role", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
primaryRoleID := uuidv7.MustBytes()
extraRoleID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
RoleID: primaryRoleID,
RoleIDs: [][]byte{primaryRoleID, extraRoleID},
}
roles := newMockRoleRepo()
roles.hasRolePermissionByRole[string(primaryRoleID)] = false
roles.hasRolePermissionByRole[string(extraRoleID)] = true
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
allowed, err := svc.HasPermission(context.Background(), userID, "users.read")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !allowed {
t.Fatalf("expected permission allowed from additional role")
}
})
}
func TestAuthService_GetPermissionsByRoleID(t *testing.T) {
t.Run("role repo not configured", func(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected role repo not configured error")
}
})
t.Run("success", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
roles.listPermissionsByRoleIDRet = []auth.Permission{
{
ID: uuidv7.MustBytes(),
Name: "Users Read",
Key: "users.read",
},
}
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
perms, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(perms) != 1 || perms[0].Key != "users.read" {
t.Fatalf("unexpected permissions result: %+v", perms)
}
})
t.Run("role repo error", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
roles.listPermissionsByRoleIDErr = errors.New("list permissions error")
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetPermissionsByRoleID(context.Background(), uuidv7.MustBytes()); !errors.Is(err, roles.listPermissionsByRoleIDErr) {
t.Fatalf("expected list permissions error, got %v", err)
}
})
}
func TestAuthService_GetPermissionsByUserID(t *testing.T) {
t.Run("auth repo not configured", func(t *testing.T) {
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(nil, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetPermissionsByUserID(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected auth repo not configured error")
}
})
t.Run("role repo not configured", func(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, nil, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetPermissionsByUserID(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected role repo not configured error")
}
})
t.Run("deduplicates permissions across roles", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
primaryRoleID := uuidv7.MustBytes()
extraRoleID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
RoleID: primaryRoleID,
RoleIDs: [][]byte{primaryRoleID, extraRoleID},
}
readPerm := auth.Permission{ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read"}
updatePerm := auth.Permission{ID: uuidv7.MustBytes(), Name: "Update User", Key: "user.update"}
roles := newMockRoleRepo()
roles.listPermissionsByRole[string(primaryRoleID)] = []auth.Permission{readPerm}
roles.listPermissionsByRole[string(extraRoleID)] = []auth.Permission{readPerm, updatePerm}
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
perms, err := svc.GetPermissionsByUserID(context.Background(), userID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(perms) != 2 {
t.Fatalf("expected 2 merged permissions, got %d", len(perms))
}
if perms[0].Key != "user.read" || perms[1].Key != "user.update" {
t.Fatalf("unexpected permissions order/content: %+v", perms)
}
})
t.Run("role repo error", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
roleID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}}
roles := newMockRoleRepo()
roles.listPermissionsByRoleIDErr = errors.New("list permissions error")
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.GetPermissionsByUserID(context.Background(), userID); !errors.Is(err, roles.listPermissionsByRoleIDErr) {
t.Fatalf("expected list permissions error, got %v", err)
}
})
}
func TestAuthService_CreateChallenge_NoTokenStore(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.CreateTOTPChallenge(context.Background(), uuidv7.MustBytes()); err == nil {
t.Fatalf("expected error when token store nil")
}
}
func TestAuthService_SplitName(t *testing.T) {
if a, b := splitName(""); a != "User" || b != "" {
t.Fatalf("unexpected split for empty")
}
if a, b := splitName("John"); a != "John" || b != "" {
t.Fatalf("unexpected split for single")
}
if a, b := splitName("John Doe"); a != "John" || b != "Doe" {
t.Fatalf("unexpected split for two")
}
}
func TestAuthService_TOTPFlow(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("protector: %v", err)
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
TOTPChallengeTTL: time.Minute,
TOTPIssuer: "Wucher",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
userID := uuidv7.MustBytes()
secret, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
if err != nil {
t.Fatalf("setup totp: %v", err)
}
code, err := totp.GenerateCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("generate code: %v", err)
}
ok, err := svc.ConfirmTOTP(context.Background(), userID, code)
if err != nil || !ok {
t.Fatalf("confirm totp failed")
}
enabled, _ := svc.IsTOTPEnabled(context.Background(), userID)
if !enabled {
t.Fatalf("expected totp enabled")
}
}
func TestAuthService_TOTPChallenge_InvalidEmpty(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.ConsumeTOTPChallenge(context.Background(), ""); err == nil {
t.Fatalf("expected error for empty challenge")
}
}
func TestAuthService_TOTPChallenge_RetryAndAttemptLimit(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{TOTPChallengeTTL: time.Minute}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
userID := uuidv7.MustBytes()
token, err := svc.CreateTOTPChallenge(context.Background(), userID)
if err != nil {
t.Fatalf("create challenge failed: %v", err)
}
got, err := svc.ResolveTOTPChallenge(context.Background(), token)
if err != nil || string(got) != string(userID) {
t.Fatalf("resolve challenge failed: %v", err)
}
for i := 1; i < svc.TOTPChallengeMaxAttempts(); i++ {
remaining, regErr := svc.RegisterFailedTOTPChallengeAttempt(context.Background(), token)
if regErr != nil {
t.Fatalf("unexpected attempt err at #%d: %v", i, regErr)
}
if remaining != svc.TOTPChallengeMaxAttempts()-i {
t.Fatalf("unexpected remaining attempts at #%d: got %d", i, remaining)
}
}
if _, regErr := svc.RegisterFailedTOTPChallengeAttempt(context.Background(), token); !errors.Is(regErr, ErrTOTPChallengeAttemptLimit) {
t.Fatalf("expected attempt limit error, got %v", regErr)
}
if _, err := svc.ResolveTOTPChallenge(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token after attempt limit, got %v", err)
}
}
func TestAuthService_IssueTokens_NoTokenStore(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
if _, err := svc.IssueTokens(context.Background(), user); err != nil {
t.Fatalf("issue tokens should not require token store: %v", err)
}
}
func TestAuthService_TokenRefresh(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
refreshed, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
if err != nil {
t.Fatalf("refresh tokens: %v", err)
}
duplicate, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
if err != nil {
t.Fatalf("duplicate refresh tokens: %v", err)
}
if duplicate.AccessToken != refreshed.AccessToken || duplicate.RefreshToken != refreshed.RefreshToken {
t.Fatalf("expected duplicate refresh to return same token pair")
}
// the refreshed token must remain usable after the replay window handles the stale one
if _, err := svc.RefreshTokens(context.Background(), refreshed.RefreshToken); err != nil {
t.Fatalf("expected refreshed token to remain valid, got %v", err)
}
}
func TestAuthService_TokenRefresh_WaitsForReplayDuringSlowRotation(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "slow-replay@example.com"}
repo.CreateUser(context.Background(), user)
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
tokens.setDelay = 1500 * time.Millisecond
defer func() { tokens.setDelay = 0 }()
type refreshResult struct {
pair TokenPair
err error
}
firstCh := make(chan refreshResult, 1)
secondCh := make(chan refreshResult, 1)
go func() {
p, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
firstCh <- refreshResult{pair: p, err: err}
}()
time.Sleep(50 * time.Millisecond)
go func() {
p, err := svc.RefreshTokens(context.Background(), pair.RefreshToken)
secondCh <- refreshResult{pair: p, err: err}
}()
first := <-firstCh
if first.err != nil {
t.Fatalf("first refresh: %v", first.err)
}
second := <-secondCh
if second.err != nil {
t.Fatalf("second refresh: %v", second.err)
}
if second.pair.AccessToken != first.pair.AccessToken || second.pair.RefreshToken != first.pair.RefreshToken {
t.Fatalf("expected replayed refresh pair to match first refresh")
}
}
func TestAuthService_ReplaceSessionForSameDeviceType(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: 10 * time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
first, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh; Intel Mac OS X)", "127.0.0.1")
if err != nil {
t.Fatalf("first issue tokens: %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); err != nil {
t.Fatalf("first access token should be valid, got %v", err)
}
second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "127.0.0.2")
if err != nil {
t.Fatalf("second issue tokens: %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected first access token invalid after second login, got %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), first.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected first refresh token invalid after second login, got %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), second.AccessToken); err != nil {
t.Fatalf("second access token should remain valid, got %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); err != nil {
t.Fatalf("expected second refresh token to stay valid, got %v", err)
}
sessions, err := svc.ListUserSessions(context.Background(), user.ID, second.SessionID)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 1 || sessions[0].ID != second.SessionID || !sessions[0].ActiveNow {
t.Fatalf("expected only latest session to remain active, got %#v", sessions)
}
}
func TestAuthService_SessionListAndRevoke(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: 10 * time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
pair, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh; Intel Mac OS X)", "127.0.0.1")
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
sessions, err := svc.ListUserSessions(context.Background(), user.ID, pair.SessionID)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 1 || sessions[0].ID != pair.SessionID || !sessions[0].ActiveNow {
t.Fatalf("expected current session only, got %#v", sessions)
}
if err := svc.RevokeUserSession(context.Background(), user.ID, pair.SessionID); err != nil {
t.Fatalf("revoke session: %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected refresh token invalid after session revoke, got %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected access token invalid after current session revoke, got %v", err)
}
sessions, err = svc.ListUserSessions(context.Background(), user.ID, "")
if err != nil {
t.Fatalf("list sessions after revoke: %v", err)
}
if len(sessions) != 0 {
t.Fatalf("expected no sessions after revoke, got %#v", sessions)
}
}
func TestAuthService_RevokeAllUserSessions(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: 10 * time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
first, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue first tokens: %v", err)
}
second, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue second tokens: %v", err)
}
if err := svc.RevokeAllUserSessions(context.Background(), user.ID); err != nil {
t.Fatalf("revoke all sessions: %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected first access invalid after revoke all, got %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), second.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected second access invalid after revoke all, got %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), first.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected first refresh invalid after revoke all, got %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected second refresh invalid after revoke all, got %v", err)
}
}
func TestAuthService_DeviceSlotPolicy_ReplacePerType(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: 10 * time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
desktop1, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh; Intel Mac OS X)", "127.0.0.1")
if err != nil {
t.Fatalf("issue desktop1: %v", err)
}
mobile1, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile", "127.0.0.2")
if err != nil {
t.Fatalf("issue mobile1: %v", err)
}
// second desktop login replaces previous desktop only.
desktop2, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "127.0.0.3")
if err != nil {
t.Fatalf("issue desktop2: %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), desktop1.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected first desktop access invalid after replacement, got %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), mobile1.AccessToken); err != nil {
t.Fatalf("expected mobile session to remain valid, got %v", err)
}
if _, err := svc.ParseAccessToken(context.Background(), desktop2.AccessToken); err != nil {
t.Fatalf("expected second desktop session valid, got %v", err)
}
sessions, err := svc.ListUserSessions(context.Background(), user.ID, desktop2.SessionID)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("expected exactly 2 sessions (desktop+mobile), got %d", len(sessions))
}
}
func TestAuthService_SessionHelpersAndBranches(t *testing.T) {
newSessionService := func() (*mockAuthRepo, *mockTokenStore, *AuthService, *auth.User) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: 10 * time.Minute,
JWTRefreshTTL: time.Hour,
JWTRefreshTOTPTTL: 2 * time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
_ = repo.CreateUser(context.Background(), user)
return repo, tokens, svc, user
}
t.Run("issue tokens after totp success and invalid user", func(t *testing.T) {
_, _, svc, user := newSessionService()
pair, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), user, "Mozilla/5.0 (X11; Linux) Firefox/120.0", "127.0.0.1")
if err != nil {
t.Fatalf("issue after totp: %v", err)
}
if pair.RefreshTTL != 2*time.Hour {
t.Fatalf("expected refresh ttl from totp config, got %v", pair.RefreshTTL)
}
if _, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), &auth.User{}, "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for invalid user, got %v", err)
}
})
t.Run("issue login tokens helper invalid user", func(t *testing.T) {
_, _, svc, _ := newSessionService()
if _, err := svc.issueLoginTokensWithRefreshTTLAndClient(context.Background(), &auth.User{}, time.Hour, false, "", "", "", "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("parse access token session id success and error", func(t *testing.T) {
_, _, svc, user := newSessionService()
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
sid, err := svc.ParseAccessTokenSessionID(context.Background(), pair.AccessToken)
if err != nil || sid != pair.SessionID {
t.Fatalf("expected session id %q, got %q err=%v", pair.SessionID, sid, err)
}
if _, err := svc.ParseAccessTokenSessionID(context.Background(), "bad"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
})
t.Run("refresh tokens branches", func(t *testing.T) {
repo, tokens, svc, user := newSessionService()
pair, err := svc.IssueTokensAfterTOTPWithClient(context.Background(), user, "Mozilla/5.0 (Windows NT 10.0) Edg/120", "127.0.0.1")
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
refreshed, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", "")
if err != nil {
t.Fatalf("refresh tokens: %v", err)
}
if refreshed.RefreshTTL != svc.cfg.JWTRefreshTOTPTTL {
t.Fatalf("expected totp refresh ttl preserved, got %v", refreshed.RefreshTTL)
}
sub, err := uuidv7.BytesToString(user.ID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
claims := jwt.MapClaims{
"sub": sub,
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"type": "refresh",
"sid": pair.SessionID,
}
tokenMissingJTI, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(svc.cfg.JWTRefreshSecret))
if _, err := svc.RefreshTokensWithClient(context.Background(), tokenMissingJTI, "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for missing jti, got %v", err)
}
pair2, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue second token: %v", err)
}
tokens.store[sessionKey(pair2.SessionID)] = []byte("not-json")
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for bad session payload, got %v", err)
}
pair3, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue third token: %v", err)
}
delete(repo.users, string(user.ID))
delete(repo.usersByMail, user.Email)
if _, err := svc.RefreshTokensWithClient(context.Background(), pair3.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token when user missing, got %v", err)
}
})
t.Run("refresh microsoft session validates provider connectivity", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
ms := &mockTokenSSO{
mockSSO: mockSSO{
claims: MicrosoftClaims{
Subject: "sub",
Email: "user@example.com",
TenantID: "tenant",
ObjectID: "oid",
},
},
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 1,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
SSO: ms,
Protector: &errorProtector{},
Config: cfg,
})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
t.Fatalf("mark email verified: %v", err)
}
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
AccessToken: "old_access",
RefreshToken: "old_refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}); err != nil {
t.Fatalf("store ms token: %v", err)
}
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token: %v", err)
}
ms.refreshSet = MicrosoftTokenSet{
AccessToken: "new_access",
RefreshToken: "new_refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}
if _, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", ""); err != nil {
t.Fatalf("expected microsoft session refresh success, got %v", err)
}
pair2, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token second: %v", err)
}
ms.refreshErr = errors.New("token exchange failed: invalid_grant")
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token when microsoft disconnected, got %v", err)
}
})
t.Run("refresh microsoft session survives transient provider errors", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
ms := &mockTokenSSO{
mockSSO: mockSSO{
claims: MicrosoftClaims{
Subject: "sub",
Email: "user@example.com",
TenantID: "tenant",
ObjectID: "oid",
},
},
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 1,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
SSO: ms,
Protector: &errorProtector{},
Config: cfg,
})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
t.Fatalf("mark email verified: %v", err)
}
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
AccessToken: "old_access",
RefreshToken: "old_refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}); err != nil {
t.Fatalf("store ms token: %v", err)
}
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token: %v", err)
}
// A transient Microsoft failure (network/5xx/throttle/circuit breaker)
// must NOT revoke the app session: the user stays logged in and we
// retry Microsoft on the next refresh.
ms.refreshErr = errors.New("microsoft sso unavailable: server_error (503)")
if _, err := svc.RefreshTokensWithClient(context.Background(), pair.RefreshToken, "", ""); err != nil {
t.Fatalf("expected transient microsoft error to keep the session, got %v", err)
}
// The session must still be usable: once Microsoft recovers, a normal
// refresh succeeds.
ms.refreshErr = nil
ms.refreshSet = MicrosoftTokenSet{
AccessToken: "new_access",
RefreshToken: "new_refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}
pair2, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token second: %v", err)
}
if _, err := svc.RefreshTokensWithClient(context.Background(), pair2.RefreshToken, "", ""); err != nil {
t.Fatalf("expected refresh success after microsoft recovery, got %v", err)
}
})
t.Run("microsoft session refresh ttl follows microsoft refresh token", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
ms := &mockTokenSSO{
mockSSO: mockSSO{
claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", TenantID: "tenant", ObjectID: "oid"},
},
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 1,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour, // short baseline that must be overridden for SSO
JWTSSORefreshTTL: 90 * 24 * time.Hour, // fallback when microsoft omits the expiry
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
SSO: ms,
Protector: &errorProtector{},
Config: cfg,
})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", IsActive: true}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
t.Fatalf("mark email verified: %v", err)
}
// Microsoft reports a 30-day refresh token lifetime: the app session
// must follow it, not the 1h JWTRefreshTTL baseline.
const msRefreshTTL = 30 * 24 * time.Hour
if err := svc.storeMicrosoftOAuthToken(context.Background(), user.ID, ms.claims, MicrosoftTokenSet{
AccessToken: "access",
RefreshToken: "refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
RefreshTokenExpiresIn: int64(msRefreshTTL.Seconds()),
}); err != nil {
t.Fatalf("store ms token: %v", err)
}
pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token: %v", err)
}
if pair.RefreshTTL <= time.Hour {
t.Fatalf("expected sso refresh ttl to follow microsoft (~30d), got %s", pair.RefreshTTL)
}
if diff := msRefreshTTL - pair.RefreshTTL; diff < 0 || diff > 5*time.Minute {
t.Fatalf("expected refresh ttl ~= microsoft 30d, got %s (diff %s)", pair.RefreshTTL, diff)
}
// When Microsoft does not report an expiry, fall back to the SSO default.
repo2 := newMockAuthRepo()
svc2 := NewAuthService(repo2, roles, newMockTokenStore(), nil, AuthServiceDependencies{
SSO: ms,
Protector: &errorProtector{},
Config: cfg,
})
user2 := &auth.User{ID: uuidv7.MustBytes(), Email: "user2@example.com", IsActive: true}
if err := repo2.CreateUser(context.Background(), user2); err != nil {
t.Fatalf("create user2: %v", err)
}
if err := repo2.MarkEmailVerified(context.Background(), user2.ID); err != nil {
t.Fatalf("mark email verified user2: %v", err)
}
if err := svc2.storeMicrosoftOAuthToken(context.Background(), user2.ID, ms.claims, MicrosoftTokenSet{
AccessToken: "access",
RefreshToken: "refresh",
IDToken: testMicrosoftIDToken("user2@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}); err != nil {
t.Fatalf("store ms token user2: %v", err)
}
pair2, err := svc2.IssueTokensWithClientForProvider(context.Background(), user2, "Mozilla/5.0", "127.0.0.1", ssoProviderMicrosoft)
if err != nil {
t.Fatalf("issue provider token user2: %v", err)
}
if diff := cfg.JWTSSORefreshTTL - pair2.RefreshTTL; diff < 0 || diff > 5*time.Minute {
t.Fatalf("expected fallback ttl ~= sso default (90d), got %s", pair2.RefreshTTL)
}
})
t.Run("revoke refresh token with session branches", func(t *testing.T) {
_, tokens, svc, user := newSessionService()
if err := svc.revokeRefreshTokenWithSession(context.Background(), ""); err != nil {
t.Fatalf("expected nil for empty token, got %v", err)
}
if err := svc.revokeRefreshTokenWithSession(context.Background(), "bad"); err != nil {
t.Fatalf("expected nil for invalid token parse, got %v", err)
}
claims := jwt.MapClaims{
"sub": "not-a-uuid",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"jti": "jti-1",
"type": "refresh",
"sid": "sid-1",
}
tokenBadSub, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(svc.cfg.JWTRefreshSecret))
tokens.store[refreshKey("jti-1")] = []byte("sid-1")
if err := svc.revokeRefreshTokenWithSession(context.Background(), tokenBadSub); err != nil {
t.Fatalf("expected nil for bad sub revoke, got %v", err)
}
if _, ok := tokens.store[refreshKey("jti-1")]; ok {
t.Fatalf("expected fallback delete when sub is invalid")
}
sub, err := uuidv7.BytesToString(user.ID)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
claimsNoSID := jwt.MapClaims{
"sub": sub,
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"jti": "jti-2",
"type": "refresh",
}
tokenNoSID, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsNoSID).SignedString([]byte(svc.cfg.JWTRefreshSecret))
tokens.store[refreshKey("jti-2")] = []byte("sid-2")
if err := svc.revokeRefreshTokenWithSession(context.Background(), tokenNoSID); err != nil {
t.Fatalf("expected nil for fallback delete, got %v", err)
}
if _, ok := tokens.store[refreshKey("jti-2")]; ok {
t.Fatalf("expected refresh key deleted")
}
})
t.Run("list user sessions branches", func(t *testing.T) {
_, tokens, svc, user := newSessionService()
if _, err := svc.ListUserSessions(context.Background(), []byte("short"), ""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for invalid user id, got %v", err)
}
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
sessions, err := svcNoTokens.ListUserSessions(context.Background(), uuidv7.MustBytes(), "")
if err != nil || len(sessions) != 0 {
t.Fatalf("expected empty sessions without token store, got len=%d err=%v", len(sessions), err)
}
tokens.getErr = errors.New("get failed")
if _, err := svc.ListUserSessions(context.Background(), user.ID, ""); !errors.Is(err, tokens.getErr) {
t.Fatalf("expected get err, got %v", err)
}
tokens.getErr = nil
tokens.store[userSessionsKey(user.ID)] = []byte(`["sid-a","sid-b","sid-c"]`)
tokens.store[sessionKey("sid-a")] = []byte("not-json")
otherUserID := uuidv7.MustBytes()
recordB := &authSessionRecord{SessionID: "sid-b", UserID: bytesToString(otherUserID), LastActiveAt: time.Now()}
recordC := &authSessionRecord{SessionID: "sid-c", UserID: bytesToString(user.ID), DeviceName: "Chrome on macOS", LastActiveAt: time.Now().Add(time.Minute)}
_ = svc.saveSessionRecord(context.Background(), recordB, time.Hour)
_ = svc.saveSessionRecord(context.Background(), recordC, time.Hour)
list, err := svc.ListUserSessions(context.Background(), user.ID, "sid-c")
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(list) != 1 || list[0].ID != "sid-c" || !list[0].ActiveNow {
t.Fatalf("expected only valid matching session, got %#v", list)
}
})
t.Run("revoke user session branches", func(t *testing.T) {
_, _, svc, user := newSessionService()
if err := svc.RevokeUserSession(context.Background(), []byte("short"), "sid"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for bad user id, got %v", err)
}
if err := svc.RevokeUserSession(context.Background(), user.ID, " "); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for empty session id, got %v", err)
}
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svcNoTokens.RevokeUserSession(context.Background(), user.ID, "sid"); err != nil {
t.Fatalf("expected nil without token store, got %v", err)
}
_, tokens, svc2, user2 := newSessionService()
tokens.getErr = errors.New("get failed")
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, tokens.getErr) {
t.Fatalf("expected get err, got %v", err)
}
tokens.getErr = nil
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected session not found, got %v", err)
}
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(uuidv7.MustBytes()), CurrentJTI: "jti"}
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
if err := svc2.RevokeUserSession(context.Background(), user2.ID, "sid"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for other user session, got %v", err)
}
})
t.Run("is session active branches", func(t *testing.T) {
_, _, _, user := newSessionService()
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if svcNoTokens.isSessionActive(context.Background(), user.ID, "sid") {
t.Fatalf("expected inactive when token store unavailable")
}
_, tokens, svc2, user2 := newSessionService()
tokens.getErr = errors.New("get failed")
if svc2.isSessionActive(context.Background(), user2.ID, "sid") {
t.Fatalf("expected inactive on token store error")
}
tokens.getErr = nil
if svc2.isSessionActive(context.Background(), user2.ID, "missing") {
t.Fatalf("expected inactive when no record")
}
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(uuidv7.MustBytes())}
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
if svc2.isSessionActive(context.Background(), user2.ID, "sid") {
t.Fatalf("expected inactive for other user session")
}
record.UserID = bytesToString(user2.ID)
_ = svc2.saveSessionRecord(context.Background(), record, time.Hour)
_ = svc2.replaceUserSessionIDs(context.Background(), user2.ID, []string{"sid"})
if !svc2.isSessionActive(context.Background(), user2.ID, "sid") {
t.Fatalf("expected active for matching session")
}
})
t.Run("save session record branches", func(t *testing.T) {
_, _, svc, _ := newSessionService()
if err := svc.saveSessionRecord(context.Background(), nil, time.Hour); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for nil session, got %v", err)
}
if err := svc.saveSessionRecord(context.Background(), &authSessionRecord{}, time.Hour); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for empty session id, got %v", err)
}
_, tokens, svc2, user := newSessionService()
record := &authSessionRecord{SessionID: "sid", UserID: bytesToString(user.ID)}
if err := svc2.saveSessionRecord(context.Background(), record, time.Hour); err != nil {
t.Fatalf("unexpected save error: %v", err)
}
if _, ok := tokens.store[sessionKey("sid")]; !ok {
t.Fatalf("expected session payload saved")
}
})
t.Run("add user session id branches", func(t *testing.T) {
_, _, svc, user := newSessionService()
if err := svc.addUserSessionID(context.Background(), user.ID, " "); err != nil {
t.Fatalf("expected nil for empty session id, got %v", err)
}
_, tokens, svc2, user2 := newSessionService()
tokens.getErr = errors.New("get failed")
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid"); !errors.Is(err, tokens.getErr) {
t.Fatalf("expected get err, got %v", err)
}
tokens.getErr = nil
tokens.store[userSessionsKey(user2.ID)] = []byte(`["sid"]`)
setCountBefore := tokens.setCount
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid"); err != nil {
t.Fatalf("unexpected duplicate add error: %v", err)
}
if tokens.setCount != setCountBefore {
t.Fatalf("expected duplicate session id not to be written again")
}
if err := svc2.addUserSessionID(context.Background(), user2.ID, "sid-2"); err != nil {
t.Fatalf("unexpected add error: %v", err)
}
ids, err := svc2.loadUserSessionIDs(context.Background(), user2.ID)
if err != nil || len(ids) != 2 {
t.Fatalf("expected two session ids, got %#v err=%v", ids, err)
}
})
t.Run("derive device name variants", func(t *testing.T) {
cases := map[string]string{
"": "Unknown device",
"Mozilla/5.0 (iPhone) Safari/605.1": "Safari on iPhone",
"Mozilla/5.0 (iPad) Safari/605.1": "Safari on iPad",
"Mozilla/5.0 (Linux; Android 14) Chrome/120.0": "Chrome on Android",
"Mozilla/5.0 (Macintosh; Intel Mac OS X) Firefox/120.0": "Firefox on macOS",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edg/120.0": "Edge on Windows",
"Mozilla/5.0 (X11; Linux x86_64)": "Unknown browser on Linux",
}
for ua, want := range cases {
if got := deriveDeviceName(ua); got != want {
t.Fatalf("deriveDeviceName(%q) = %q, want %q", ua, got, want)
}
}
})
}
func TestAuthService_RefreshTokens_InvalidToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTRefreshSecret: "refresh",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if _, err := svc.RefreshTokens(context.Background(), "bad"); err == nil {
t.Fatalf("expected invalid token")
}
}
func TestAuthService_RefreshTokens_NoTokenStore(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, _ := svc.IssueTokens(context.Background(), user)
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token without token store, got %v", err)
}
}
func TestAuthService_RefreshTokens_MissingJTI(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTRefreshSecret: "refresh",
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
// create token without jti
SSO: nil, Protector: nil, Config: cfg})
claims := map[string]any{
"sub": "invalid",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"type": "refresh",
}
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
t.Fatalf("expected invalid token (missing jti)")
}
}
func TestAuthService_RefreshTokens_MissingSub(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTRefreshSecret: "refresh",
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
claims := map[string]any{
"jti": "jti1",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"type": "refresh",
}
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
t.Fatalf("expected invalid token (missing sub)")
}
}
func TestAuthService_RefreshTokens_TokenStoreMissing(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, _ := svc.IssueTokens(context.Background(), user)
// clear token store to simulate missing jti
tokens.store = map[string][]byte{}
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); err == nil {
t.Fatalf("expected invalid token when jti missing in store")
}
}
func TestAuthService_RefreshTokens_UserNotFound(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{
// Create token with valid jti stored but user missing
SSO: nil, Protector: nil, Config: cfg})
jti := "jti2"
_ = tokens.Set(context.Background(), "auth:refresh:"+jti, []byte("1"), time.Hour)
claims := map[string]any{
"jti": jti,
"sub": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"type": "refresh",
}
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(cfg.JWTRefreshSecret))
if _, err := svc.RefreshTokens(context.Background(), tok); err == nil {
t.Fatalf("expected invalid token when user not found")
}
}
func TestAuthService_VerifyEmail(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
adminID := uuidv7.MustBytes()
roles.rolesByName["admin"] = &auth.Role{ID: adminID, Name: "admin"}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
EmailVerifyTTL: time.Hour,
EmailVerifyURL: "http://localhost/verify",
DefaultRoleName: "admin",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", adminID)
if err != nil {
t.Fatalf("register failed: %v", err)
}
if tokens.lastKey == "" {
t.Fatalf("expected email verify token in store")
}
token := tokens.lastKey[len("auth:email_verify:"):]
if err := svc.VerifyEmail(context.Background(), token); err != nil {
t.Fatalf("verify email failed: %v", err)
}
if user.EmailVerifiedAt == nil {
t.Fatalf("email not verified")
}
}
func TestAuthService_TOTPChallenge(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
TOTPChallengeTTL: time.Minute,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
userID := uuidv7.MustBytes()
token, err := svc.CreateTOTPChallenge(context.Background(), userID)
if err != nil || token == "" {
t.Fatalf("create challenge failed")
}
got, err := svc.ConsumeTOTPChallenge(context.Background(), token)
if err != nil {
t.Fatalf("consume challenge failed: %v", err)
}
if string(got) != string(userID) {
t.Fatalf("unexpected user id")
}
}
func TestAuthService_RandomToken_Uniqueness(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
TOTPChallengeTTL: time.Minute,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
userID := uuidv7.MustBytes()
token1, err := svc.CreateTOTPChallenge(context.Background(), userID)
if err != nil || token1 == "" {
t.Fatalf("create challenge 1 failed")
}
token2, err := svc.CreateTOTPChallenge(context.Background(), userID)
if err != nil || token2 == "" {
t.Fatalf("create challenge 2 failed")
}
if token1 == token2 {
t.Fatalf("expected different tokens")
}
if tokens.setCount < 2 {
t.Fatalf("expected token store set twice")
}
}
func TestAuthService_RandomToken_Error(t *testing.T) {
orig := rand.Reader
rand.Reader = errReader{}
defer func() { rand.Reader = orig }()
if _, err := randomToken(16); err == nil {
t.Fatalf("expected randomToken error")
}
}
type errReader struct{}
func (e errReader) Read(p []byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}
func TestAuthService_ParseAccessToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
id, err := svc.ParseAccessToken(context.Background(), pair.AccessToken)
if err != nil || string(id) != string(user.ID) {
t.Fatalf("parse access token failed")
}
user.IsActive = false
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected inactive user access token to be rejected, got %v", err)
}
}
type mockSSO struct {
claims MicrosoftClaims
err error
authURL string
}
type mockTokenSSO struct {
mockSSO
refreshSet MicrosoftTokenSet
refreshErr error
}
func (m *mockSSO) AuthCodeURL(state string) string {
if strings.TrimSpace(m.authURL) != "" {
return fmt.Sprintf(m.authURL, state)
}
return "http://example.com?state=" + state
}
func (m *mockSSO) LogoutURL(postLogoutRedirectURL string) string {
if strings.TrimSpace(postLogoutRedirectURL) == "" {
return "http://example.com/logout"
}
return "http://example.com/logout?post_logout_redirect_uri=" + url.QueryEscape(postLogoutRedirectURL)
}
func (m *mockSSO) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) {
if m.err != nil {
return MicrosoftClaims{}, m.err
}
return m.claims, nil
}
func (m *mockTokenSSO) ExchangeCodeAndTokens(ctx context.Context, code, codeVerifier string) (MicrosoftTokenSet, MicrosoftClaims, error) {
if m.err != nil {
return MicrosoftTokenSet{}, MicrosoftClaims{}, m.err
}
return MicrosoftTokenSet{
AccessToken: "ms_access",
RefreshToken: "ms_refresh",
IDToken: testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid"),
TokenType: "Bearer",
Scope: "openid profile email",
ExpiresIn: 3600,
}, m.claims, nil
}
func (m *mockTokenSSO) RefreshAccessToken(ctx context.Context, refreshToken, codeVerifier string) (MicrosoftTokenSet, error) {
if m.refreshErr != nil {
return MicrosoftTokenSet{}, m.refreshErr
}
if m.refreshSet.IDToken == "" {
m.refreshSet.IDToken = testMicrosoftIDToken("user@example.com", "sub", "tenant", "oid")
}
if m.refreshSet.AccessToken == "" {
m.refreshSet.AccessToken = "ms_access_refreshed"
}
return m.refreshSet, nil
}
func testMicrosoftIDToken(email, sub, tid, oid string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payload := fmt.Sprintf(`{"sub":"%s","tid":"%s","oid":"%s","email":"%s","upn":"%s"}`, sub, tid, oid, email, email)
body := base64.RawURLEncoding.EncodeToString([]byte(payload))
return header + "." + body + "."
}
func TestAuthService_LinkSSOForUser(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
userID := uuidv7.MustBytes()
userRoleID := uuidv7.MustBytes()
if err := repo.CreateUser(context.Background(), &auth.User{
ID: userID,
Email: "marco@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "marco@example.com", Name: "Jane Doe"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
identity, err := svc.LinkSSOForUser(context.Background(), userID, "microsoft", "code")
if err != nil || identity == nil {
t.Fatalf("link sso failed: %v", err)
}
if !bytes.Equal(identity.UserID, userID) {
t.Fatalf("expected identity linked to user")
}
}
func TestAuthService_LinkSSOForUser_RejectWhenLinkedToAnotherUser(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
userRoleID := uuidv7.MustBytes()
userID := uuidv7.MustBytes()
otherUserID := uuidv7.MustBytes()
if err := repo.CreateUser(context.Background(), &auth.User{
ID: userID,
Email: "user@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := repo.CreateUser(context.Background(), &auth.User{
ID: otherUserID,
Email: "other@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: otherUserID,
Provider: "microsoft",
ProviderSubject: "sub",
Email: "other@example.com",
})
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})
if _, err := svc.LinkSSOForUser(context.Background(), userID, "microsoft", "code"); !errors.Is(err, ErrSSOLinkedToAnotherUser) {
t.Fatalf("expected ErrSSOLinkedToAnotherUser, got %v", err)
}
}
func TestAuthService_BeginCompleteMicrosoftLogin(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
userRoleID := uuidv7.MustBytes()
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
userID := uuidv7.MustBytes()
if err := repo.CreateUser(context.Background(), &auth.User{
ID: userID,
Email: "user@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: userID,
Provider: "microsoft",
ProviderSubject: "sub",
Email: "user@example.com",
})
cfg := config.AuthConfig{
SSOStateTTL: time.Minute,
}
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
authURL, err := svc.BeginMicrosoftLogin(context.Background())
if err != nil || authURL == "" {
t.Fatalf("expected auth url")
}
// extract state from mock url
parsedURL, parseErr := url.Parse(authURL)
if parseErr != nil {
t.Fatalf("parse auth url: %v", parseErr)
}
state := parsedURL.Query().Get("state")
identity, _, err := svc.CompleteMicrosoftLogin(context.Background(), "code", state)
if err != nil || identity == nil {
t.Fatalf("complete login failed: %v", err)
}
}
func TestAuthService_BeginCompleteMicrosoftLink(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
userRoleID := uuidv7.MustBytes()
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
userID := uuidv7.MustBytes()
if err := repo.CreateUser(context.Background(), &auth.User{
ID: userID,
Email: "user@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := config.AuthConfig{
SSOStateTTL: time.Minute,
}
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub-link", Email: "user@example.com", Name: "Jane Doe"}}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
authURL, err := svc.BeginMicrosoftLink(context.Background(), userID)
if err != nil || authURL == "" {
t.Fatalf("expected auth url")
}
parsedURL, parseErr := url.Parse(authURL)
if parseErr != nil {
t.Fatalf("parse auth url: %v", parseErr)
}
state := parsedURL.Query().Get("state")
identity, err := svc.CompleteMicrosoftLink(context.Background(), "code", state)
if err != nil || identity == nil {
t.Fatalf("complete link failed: %v", err)
}
if identity.Provider != "microsoft" || identity.ProviderSubject != "sub-link" {
t.Fatalf("unexpected linked identity: %+v", identity)
}
}
func TestAuthService_RevokeRefreshToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
if err := svc.RevokeRefreshToken(context.Background(), pair.RefreshToken); err != nil {
t.Fatalf("revoke failed: %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); err == nil {
t.Fatalf("expected refresh to fail after revoke")
}
}
func TestAuthService_RevokeRefreshToken_Invalid(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{JWTRefreshSecret: "refresh"}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if err := svc.RevokeRefreshToken(context.Background(), "bad"); err != nil {
t.Fatalf("expected nil error")
}
}
func TestAuthService_VerifyAndDisableTOTP(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("protector: %v", err)
}
cfg := config.AuthConfig{
TOTPChallengeTTL: time.Minute,
TOTPIssuer: "Wucher",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
userID := uuidv7.MustBytes()
secret, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
if err != nil {
t.Fatalf("setup: %v", err)
}
code, _ := totp.GenerateCode(secret, time.Now().UTC())
ok, err := svc.ConfirmTOTP(context.Background(), userID, code)
if err != nil || !ok {
t.Fatalf("confirm failed")
}
ok, err = svc.VerifyTOTP(context.Background(), userID, code)
if err != nil || !ok {
t.Fatalf("verify failed")
}
rec, _ := repo.GetUserTOTP(context.Background(), userID)
if rec == nil || rec.LastUsedAt == nil {
t.Fatalf("last_used_at not updated")
}
if err := svc.DisableTOTP(context.Background(), userID); err != nil {
t.Fatalf("disable failed: %v", err)
}
enabled, _ := svc.IsTOTPEnabled(context.Background(), userID)
if enabled {
t.Fatalf("expected disabled")
}
secretAgain, _, _, err := svc.SetupTOTP(context.Background(), userID, "user@example.com")
if err != nil {
t.Fatalf("setup again: %v", err)
}
if secretAgain != secret {
t.Fatalf("expected existing secret to be reused after disable")
}
code2, _ := totp.GenerateCode(secretAgain, time.Now().UTC())
ok, err = svc.ConfirmTOTP(context.Background(), userID, code2)
if err != nil || !ok {
t.Fatalf("confirm after disable failed")
}
enabled, _ = svc.IsTOTPEnabled(context.Background(), userID)
if !enabled {
t.Fatalf("expected enabled after reconfirm")
}
}
func TestAuthService_SetAuditLogger(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if svc.audit != nil {
t.Fatalf("expected nil audit logger by default")
}
auditSvc := &AuditLogService{}
svc.SetAuditLogger(auditSvc)
if svc.audit != auditSvc {
t.Fatalf("expected audit logger to be set")
}
svc.SetAuditLogger(nil)
if svc.audit != nil {
t.Fatalf("expected audit logger to be cleared")
}
}
func TestAuthService_logServiceAudit(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
// Should no-op when audit logger is not configured.
svc.logServiceAudit(context.Background(), "auth.test.noop", nil, false, "noop", nil)
auditRepo := &mockAuditRepo{}
auditSvc := NewAuditLogService(auditRepo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
svc.SetAuditLogger(auditSvc)
userID := uuidv7.MustBytes()
svc.logServiceAudit(context.Background(), "auth.test.action", userID, true, "ok", map[string]any{
"source": "unit-test",
})
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
auditRepo.mu.Lock()
n := len(auditRepo.entries)
auditRepo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
auditRepo.mu.Lock()
defer auditRepo.mu.Unlock()
if len(auditRepo.entries) != 1 {
t.Fatalf("expected 1 audit entry, got %d", len(auditRepo.entries))
}
entry := auditRepo.entries[0]
if entry.Layer != "service" {
t.Fatalf("expected layer service, got %q", entry.Layer)
}
if entry.Action != "auth.test.action" {
t.Fatalf("expected action auth.test.action, got %q", entry.Action)
}
if !entry.Success {
t.Fatalf("expected success=true")
}
if entry.Message != "ok" {
t.Fatalf("expected message ok, got %q", entry.Message)
}
if string(entry.ActorUserID) != string(userID) {
t.Fatalf("expected actor user id to match")
}
}
func TestAuthService_Getters(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
roleID := uuidv7.MustBytes()
roles.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"}
cfg := config.AuthConfig{
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
JWTCookieDomain: "example.com",
JWTCookieSecure: true,
JWTCookieSameSite: "Lax",
JWTAccessCookieName: "at",
JWTRefreshCookieName: "rt",
ForgotPasswordIPRateMax: 7,
ForgotPasswordIPRateWindow: 2 * time.Minute,
DefaultRoleName: "admin",
DisableRegister: true,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
_ = svc.AccessTTL()
_ = svc.RefreshTTL()
_ = svc.CookieDomain()
_ = svc.CookieSecure()
_ = svc.CookieSameSite()
_ = svc.AccessCookieName()
_ = svc.RefreshCookieName()
_ = svc.DefaultRoleName()
_ = svc.RegisterDisabled()
if got := svc.ForgotPasswordIPRateMax(); got != cfg.ForgotPasswordIPRateMax {
t.Fatalf("expected ForgotPasswordIPRateMax=%d, got %d", cfg.ForgotPasswordIPRateMax, got)
}
if got := svc.ForgotPasswordIPRateWindow(); got != cfg.ForgotPasswordIPRateWindow {
t.Fatalf("expected ForgotPasswordIPRateWindow=%s, got %s", cfg.ForgotPasswordIPRateWindow, got)
}
user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com"}
repo.CreateUser(context.Background(), user)
if u, _ := svc.GetUserByID(context.Background(), user.ID); u == nil {
t.Fatalf("get user failed")
}
if r, _ := svc.GetRoleByID(context.Background(), roleID); r == nil {
t.Fatalf("get role failed")
}
}
func TestAuthService_ForgotPassword_StoresOnlyTokenHash(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
emailQueue := &mockEmailQueue{}
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
FirstName: "John",
LastName: "Doe",
RoleID: uuidv7.MustBytes(),
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := config.AuthConfig{
PasswordResetURL: "https://app.example.com/reset-password",
PasswordResetTTL: 20 * time.Minute,
ForgotPasswordEmailCooldown: 0,
ForgotPasswordMinDuration: 0,
}
svc := NewAuthService(repo, roles, nil, emailQueue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
svc.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "test-agent")
if repo.lastResetToken == nil {
t.Fatalf("expected password reset token to be persisted")
}
if len(repo.lastResetToken.TokenHash) != sha256.Size {
t.Fatalf("expected token hash length %d, got %d", sha256.Size, len(repo.lastResetToken.TokenHash))
}
if emailQueue.last == nil {
t.Fatalf("expected reset email to be enqueued")
}
rawToken := extractTokenValue(emailQueue.last.Body)
if rawToken == "" {
t.Fatalf("expected raw token in email body")
}
if bytes.Equal([]byte(rawToken), repo.lastResetToken.TokenHash) {
t.Fatalf("raw token must not be stored in DB")
}
if !bytes.Equal(hashToken(rawToken), repo.lastResetToken.TokenHash) {
t.Fatalf("stored hash does not match hashed raw token")
}
}
func TestAuthService_ResetPassword_ExpiredTokenRejected(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com"}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
token := "expired-token"
tokenHash := hashToken(token)
repo.resetTokens[string(tokenHash)] = &auth.PasswordResetToken{
ID: uuidv7.MustBytes(),
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: time.Now().UTC().Add(-time.Minute),
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
PasswordResetTTL: 20 * time.Minute,
}
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
if err := svc.ResetPassword(context.Background(), token, "NewPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected ErrInvalidResetLink, got %v", err)
}
}
func TestAuthService_ResetPassword_SingleUseAndSessionInvalidation(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
JWTAccessSecret: "access",
JWTRefreshSecret: "refresh",
JWTAccessTTL: time.Minute,
JWTRefreshTTL: time.Hour,
PasswordResetTTL: 20 * time.Minute,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
origHash, origSalt, err := svc.hashPassword("OldPassword123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
PasswordHash: origHash,
SaltValue: origSalt,
RoleID: uuidv7.MustBytes(),
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
pair, err := svc.IssueTokens(context.Background(), user)
if err != nil {
t.Fatalf("issue tokens: %v", err)
}
tokenA := "reset-token-a"
tokenB := "reset-token-b"
hashA := hashToken(tokenA)
hashB := hashToken(tokenB)
repo.resetTokens[string(hashA)] = &auth.PasswordResetToken{
ID: uuidv7.MustBytes(),
UserID: user.ID,
TokenHash: hashA,
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
repo.resetTokens[string(hashB)] = &auth.PasswordResetToken{
ID: uuidv7.MustBytes(),
UserID: user.ID,
TokenHash: hashB,
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
if err := svc.ResetPassword(context.Background(), tokenA, "NewPassword123!Long"); err != nil {
t.Fatalf("reset password: %v", err)
}
if bytes.Equal(user.PasswordHash, origHash) {
t.Fatalf("expected password hash to change")
}
if !svc.verifyPassword("NewPassword123!Long", user.PasswordHash, user.SaltValue) {
t.Fatalf("expected new password to verify")
}
if repo.resetTokens[string(hashA)].UsedAt == nil {
t.Fatalf("used token should be marked used")
}
if repo.resetTokens[string(hashB)].UsedAt == nil {
t.Fatalf("other active tokens should be invalidated")
}
if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected old access token to be invalid after reset, got %v", err)
}
if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected old refresh token to be invalid after reset, got %v", err)
}
if err := svc.ResetPassword(context.Background(), tokenA, "AnotherPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected token reuse to fail, got %v", err)
}
}
func TestAuthService_ForgotPassword_InvalidatesPreviousTokensAndAppliesCooldown(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
FirstName: "John",
LastName: "Doe",
RoleID: uuidv7.MustBytes(),
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfgNoCooldown := config.AuthConfig{
PasswordResetURL: "https://app.example.com/reset-password",
PasswordResetTTL: 20 * time.Minute,
ForgotPasswordEmailCooldown: 0,
ForgotPasswordMinDuration: 0,
}
svcNoCooldown := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfgNoCooldown})
svcNoCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua-1")
first := repo.lastResetToken
if first == nil {
t.Fatalf("expected first token")
}
svcNoCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua-2")
second := repo.lastResetToken
if second == nil || bytes.Equal(first.TokenHash, second.TokenHash) {
t.Fatalf("expected second distinct token")
}
if rec := repo.resetTokens[string(first.TokenHash)]; rec == nil || rec.UsedAt == nil {
t.Fatalf("previous active token should be invalidated by new request")
}
cfgCooldown := config.AuthConfig{
PasswordResetURL: "https://app.example.com/reset-password",
PasswordResetTTL: 20 * time.Minute,
ForgotPasswordEmailCooldown: 2 * time.Minute,
ForgotPasswordMinDuration: 0,
}
repo2 := newMockAuthRepo()
if err := repo2.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user for cooldown: %v", err)
}
svcCooldown := NewAuthService(repo2, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfgCooldown})
svcCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
countAfterFirst := len(repo2.resetTokens)
svcCooldown.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
if len(repo2.resetTokens) != countAfterFirst {
t.Fatalf("expected second request to be throttled by email cooldown")
}
}
func TestAuthService_ForgotPassword_AdditionalBranches(t *testing.T) {
t.Run("empty email returns without issuing token", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotPasswordMinDuration: 0,
}})
svc.ForgotPassword(context.Background(), " ", "127.0.0.1", "ua")
if repo.lastResetToken != nil {
t.Fatalf("did not expect reset token for empty email")
}
})
t.Run("lookup error applies cooldown and does not issue token", func(t *testing.T) {
repo := newMockAuthRepo()
repo.getByMailErr = errors.New("db lookup failed")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotPasswordEmailCooldown: time.Minute,
ForgotPasswordMinDuration: 0,
}})
svc.ForgotPassword(context.Background(), "user@example.com", "127.0.0.1", "ua")
if repo.lastResetToken != nil {
t.Fatalf("did not expect reset token when lookup fails")
}
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
}
})
t.Run("unknown email is hidden and still applies cooldown", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotPasswordEmailCooldown: time.Minute,
ForgotPasswordMinDuration: 0,
}})
svc.ForgotPassword(context.Background(), "missing@example.com", "127.0.0.1", "ua")
if repo.lastResetToken != nil {
t.Fatalf("did not expect reset token for unknown account")
}
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
}
})
t.Run("issue failure path still returns and applies cooldown", func(t *testing.T) {
repo := newMockAuthRepo()
repo.createResetTokenErr = errors.New("insert failed")
roles := newMockRoleRepo()
tokens := newMockTokenStore()
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
PasswordResetURL: "https://app.example.com/reset-password",
PasswordResetTTL: 20 * time.Minute,
ForgotPasswordEmailCooldown: time.Minute,
ForgotPasswordMinDuration: 0,
}})
svc.ForgotPassword(context.Background(), user.Email, "127.0.0.1", "ua")
if repo.lastResetToken != nil {
t.Fatalf("did not expect reset token persisted on create error")
}
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:forgot_password:cooldown:") {
t.Fatalf("expected cooldown key to be stored, got %q", tokens.lastKey)
}
})
}
func TestAuthService_ResetPassword_AdditionalBranches(t *testing.T) {
baseCfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
PasswordResetTTL: 20 * time.Minute,
}
t.Run("missing token or password", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.ResetPassword(context.Background(), "", "NewPassword123!"); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected ErrInvalidResetLink for empty token, got %v", err)
}
if err := svc.ResetPassword(context.Background(), "token", ""); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected ErrInvalidResetLink for empty password, got %v", err)
}
})
t.Run("consume repository error is returned", func(t *testing.T) {
repo := newMockAuthRepo()
repo.consumeResetErr = errors.New("consume failed")
roles := newMockRoleRepo()
svc := NewAuthService(repo, roles, nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
err := svc.ResetPassword(context.Background(), "token", "NewPassword123!")
if !errors.Is(err, repo.consumeResetErr) {
t.Fatalf("expected consume error, got %v", err)
}
})
t.Run("session version bump failure is ignored", func(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
tokens.getErr = errors.New("token store unavailable")
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
token := "reset-token"
tokenHash := hashToken(token)
repo.resetTokens[string(tokenHash)] = &auth.PasswordResetToken{
ID: uuidv7.MustBytes(),
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.ResetPassword(context.Background(), token, "NewPassword123!"); err != nil {
t.Fatalf("expected reset to succeed even when bump session version fails, got %v", err)
}
if !svc.verifyPassword("NewPassword123!", user.PasswordHash, user.SaltValue) {
t.Fatalf("expected password to be updated")
}
})
}
func TestAuthService_sendInviteSetPassword_Branches(t *testing.T) {
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "invitee@example.com",
}
t.Run("skip when token store is nil", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
t.Fatalf("expected nil error when token store is nil, got %v", err)
}
})
t.Run("skip when set password url is empty", func(t *testing.T) {
tokens := newMockTokenStore()
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
t.Fatalf("expected nil error when url is empty, got %v", err)
}
if tokens.setCount != 0 {
t.Fatalf("expected no token stored when url is empty")
}
})
t.Run("random token generation error", func(t *testing.T) {
orig := rand.Reader
rand.Reader = errReader{}
defer func() { rand.Reader = orig }()
tokens := newMockTokenStore()
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected random token error, got %v", err)
}
})
t.Run("token store error", func(t *testing.T) {
tokens := newMockTokenStore()
tokens.setErr = errors.New("token set failed")
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, tokens.setErr) {
t.Fatalf("expected token store error, got %v", err)
}
})
t.Run("queue enqueue error", func(t *testing.T) {
tokens := newMockTokenStore()
queue := &mockEmailQueue{err: errors.New("queue failed")}
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); !errors.Is(err, queue.err) {
t.Fatalf("expected queue error, got %v", err)
}
})
t.Run("queue success", func(t *testing.T) {
tokens := newMockTokenStore()
queue := &mockEmailQueue{}
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if queue.last == nil {
t.Fatalf("expected invite email to be queued")
}
if !strings.Contains(queue.last.Body, "token=") {
t.Fatalf("expected invite email body to include token")
}
})
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
tokens := newMockTokenStore()
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SetPasswordURL: "https://app.example.com/set-password",
InviteTTL: time.Minute,
}})
if err := svc.sendInviteSetPassword(context.Background(), user); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if tokens.setCount != 1 {
t.Fatalf("expected invite token to be stored once")
}
})
}
func TestAuthService_ResetSetupAndInviteJWT_Branches(t *testing.T) {
newProtectedAuthService := func(t *testing.T) (*mockAuthRepo, *AuthService, *auth.User) {
t.Helper()
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("protector: %v", err)
}
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
JWTAccessSecret: "access",
InviteTTL: time.Minute,
SetPasswordURL: "https://app.example.com/set-password",
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com"}
hash, salt, err := svc.hashPassword("Password123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user.PasswordHash = hash
user.SaltValue = salt
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("create user: %v", err)
}
return repo, svc, user
}
t.Run("delete totp setup branches", func(t *testing.T) {
repo, svc, user := newProtectedAuthService(t)
if err := svc.DeleteTOTPSetup(context.Background(), []byte("short")); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
if err := svc.DeleteTOTPSetup(context.Background(), user.ID); err != nil {
t.Fatalf("unexpected delete totp error: %v", err)
}
if _, ok := repo.totp[string(user.ID)]; ok {
t.Fatalf("expected totp setup deleted")
}
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
repo.deleteTOTPErr = errors.New("delete totp failed")
if err := svc.DeleteTOTPSetup(context.Background(), user.ID); !errors.Is(err, repo.deleteTOTPErr) {
t.Fatalf("expected delete totp error, got %v", err)
}
})
t.Run("delete webauthn setup branches", func(t *testing.T) {
repo, svc, user := newProtectedAuthService(t)
if err := svc.DeleteWebAuthnSetup(context.Background(), []byte("short")); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
if err := svc.DeleteWebAuthnSetup(context.Background(), user.ID); err != nil {
t.Fatalf("unexpected delete webauthn error: %v", err)
}
if len(repo.webauthnCredentials[string(user.ID)]) != 0 {
t.Fatalf("expected webauthn credentials deleted")
}
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
repo.deleteWebAuthnErr = errors.New("delete webauthn failed")
if err := svc.DeleteWebAuthnSetup(context.Background(), user.ID); !errors.Is(err, repo.deleteWebAuthnErr) {
t.Fatalf("expected delete webauthn error, got %v", err)
}
})
t.Run("authorize with pin or password branches", func(t *testing.T) {
repo, svc, user := newProtectedAuthService(t)
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "unknown", "", ""); !errors.Is(err, ErrInvalidReauthMethod) {
t.Fatalf("expected invalid reauth method, got %v", err)
}
repo.getByIDErr = errors.New("get user failed")
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.getByIDErr) {
t.Fatalf("expected repo get error, got %v", err)
}
repo.getByIDErr = nil
delete(repo.users, string(user.ID))
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token when user missing, got %v", err)
}
if err := repo.CreateUser(context.Background(), user); err != nil {
t.Fatalf("recreate user: %v", err)
}
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
t.Fatalf("expected password auth success, got %v", err)
}
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "123456", ""); !errors.Is(err, ErrSecurityPINNotSet) {
t.Fatalf("expected pin not set, got %v", err)
}
if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
t.Fatalf("set pin: %v", err)
}
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "000000", ""); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid security pin, got %v", err)
}
if err := svc.authorizeWithPINOrPassword(context.Background(), user.ID, "pin", "123456", ""); err != nil {
t.Fatalf("expected pin auth success, got %v", err)
}
})
t.Run("reset totp setup branches", func(t *testing.T) {
repo, svc, user := newProtectedAuthService(t)
if err := svc.ResetTOTPSetup(context.Background(), []byte("short"), "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
t.Fatalf("expected reset totp success, got %v", err)
}
repo.totp[string(user.ID)] = &auth.UserTOTP{UserID: user.ID}
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
repo.deleteTOTPErr = errors.New("delete totp failed")
if err := svc.ResetTOTPSetup(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.deleteTOTPErr) {
t.Fatalf("expected delete totp error, got %v", err)
}
})
t.Run("reset webauthn setup branches", func(t *testing.T) {
repo, svc, user := newProtectedAuthService(t)
if err := svc.ResetWebAuthnSetup(context.Background(), []byte("short"), "password", "", "Password123!"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "Password123!"); err != nil {
t.Fatalf("expected reset webauthn success, got %v", err)
}
repo.webauthnCredentials[string(user.ID)] = []auth.UserWebAuthnCredential{{ID: uuidv7.MustBytes(), UserID: user.ID}}
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "WrongPassword!"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
repo.deleteWebAuthnErr = errors.New("delete webauthn failed")
if err := svc.ResetWebAuthnSetup(context.Background(), user.ID, "password", "", "Password123!"); !errors.Is(err, repo.deleteWebAuthnErr) {
t.Fatalf("expected delete webauthn error, got %v", err)
}
})
t.Run("issue invite jwt branches", func(t *testing.T) {
_, svc, user := newProtectedAuthService(t)
if _, _, err := svc.issueInviteJWT(nil); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for nil user, got %v", err)
}
if _, _, err := svc.issueInviteJWT(&auth.User{}); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for invalid user id, got %v", err)
}
orig := rand.Reader
rand.Reader = errReader{}
if _, _, err := svc.issueInviteJWT(user); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected random token error, got %v", err)
}
rand.Reader = orig
token, jti, err := svc.issueInviteJWT(user)
if err != nil {
t.Fatalf("unexpected issue invite jwt error: %v", err)
}
if strings.TrimSpace(token) == "" || strings.TrimSpace(jti) == "" {
t.Fatalf("expected token and jti")
}
})
t.Run("parse invite jwt branches", func(t *testing.T) {
_, svc, user := newProtectedAuthService(t)
if _, _, _, err := svc.parseInviteJWT(""); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for empty token, got %v", err)
}
svcNoSecret := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if _, _, _, err := svcNoSecret.parseInviteJWT("token"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for missing secret, got %v", err)
}
if _, _, _, err := svc.parseInviteJWT("bad"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for malformed jwt, got %v", err)
}
validToken, _, err := svc.issueInviteJWT(user)
if err != nil {
t.Fatalf("issue invite jwt: %v", err)
}
gotUserID, gotJTI, gotEmail, err := svc.parseInviteJWT(validToken)
if err != nil {
t.Fatalf("parse valid invite jwt: %v", err)
}
if string(gotUserID) != string(user.ID) || strings.TrimSpace(gotJTI) == "" || gotEmail != strings.ToLower(user.Email) {
t.Fatalf("unexpected parsed invite jwt values")
}
claimsWrongType := jwt.MapClaims{
"type": "wrong",
"sub": mustUUIDStringAuth(t, user.ID),
"email": strings.ToLower(user.Email),
"jti": "jti-1",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Minute).Unix(),
}
wrongTypeToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsWrongType).SignedString([]byte(svc.cfg.JWTAccessSecret))
if _, _, _, err := svc.parseInviteJWT(wrongTypeToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for wrong type, got %v", err)
}
claimsMissing := jwt.MapClaims{
"type": "invite_set_password",
"sub": mustUUIDStringAuth(t, user.ID),
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Minute).Unix(),
}
missingClaimsToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsMissing).SignedString([]byte(svc.cfg.JWTAccessSecret))
if _, _, _, err := svc.parseInviteJWT(missingClaimsToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for missing claims, got %v", err)
}
claimsBadSub := jwt.MapClaims{
"type": "invite_set_password",
"sub": "not-a-uuid",
"email": strings.ToLower(user.Email),
"jti": "jti-2",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Minute).Unix(),
}
badSubToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsBadSub).SignedString([]byte(svc.cfg.JWTAccessSecret))
if _, _, _, err := svc.parseInviteJWT(badSubToken); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for invalid sub, got %v", err)
}
})
}
func mustUUIDStringAuth(t *testing.T, id []byte) string {
t.Helper()
s, err := uuidv7.BytesToString(id)
if err != nil {
t.Fatalf("uuid to string: %v", err)
}
return s
}
func TestAuthService_createAndSendPasswordReset_Branches(t *testing.T) {
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
}
baseCfg := config.AuthConfig{
PasswordResetURL: "https://app.example.com/reset-password",
PasswordResetTTL: 20 * time.Minute,
}
t.Run("skip when reset url is empty", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
PasswordResetURL: "",
PasswordResetTTL: 20 * time.Minute,
}})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error when url is empty, got %v", err)
}
})
t.Run("random token generation error", func(t *testing.T) {
orig := rand.Reader
rand.Reader = errReader{}
defer func() { rand.Reader = orig }()
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected random token error, got %v", err)
}
})
t.Run("invalidate active tokens error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.invalidateResetTokensErr = errors.New("invalidate failed")
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.invalidateResetTokensErr) {
t.Fatalf("expected invalidate error, got %v", err)
}
})
t.Run("create token error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.createResetTokenErr = errors.New("create reset failed")
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.createResetTokenErr) {
t.Fatalf("expected create token error, got %v", err)
}
})
t.Run("invalid reset url fails before token is persisted", func(t *testing.T) {
repo := newMockAuthRepo()
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
PasswordResetURL: "http://",
PasswordResetTTL: 20 * time.Minute,
}})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err == nil {
t.Fatalf("expected invalid password reset url error")
}
if repo.lastResetToken != nil {
t.Fatalf("token should not be persisted when reset url is invalid")
}
})
t.Run("queue enqueue error", func(t *testing.T) {
repo := newMockAuthRepo()
queue := &mockEmailQueue{err: errors.New("queue failed")}
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, queue.err) {
t.Fatalf("expected queue error, got %v", err)
}
})
t.Run("queue success", func(t *testing.T) {
repo := newMockAuthRepo()
queue := &mockEmailQueue{}
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if queue.last == nil {
t.Fatalf("expected password reset email to be queued")
}
if strings.TrimSpace(queue.last.HTMLBody) == "" {
t.Fatalf("expected non-empty html body in queued reset email")
}
})
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
repo := newMockAuthRepo()
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendPasswordReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.lastResetToken == nil {
t.Fatalf("expected reset token to be persisted")
}
})
}
func extractTokenValue(body string) string {
idx := strings.Index(body, "token=")
if idx == -1 {
return ""
}
token := body[idx+len("token="):]
if end := strings.IndexAny(token, " \n\r\t"); end >= 0 {
token = token[:end]
}
return strings.TrimSpace(token)
}
func TestAuthService_int64Claim(t *testing.T) {
tests := []struct {
name string
input any
want int64
}{
{name: "nil", input: nil, want: 0},
{name: "float64", input: float64(123.9), want: 123},
{name: "float32", input: float32(45.7), want: 45},
{name: "int", input: int(12), want: 12},
{name: "int64", input: int64(34), want: 34},
{name: "int32", input: int32(56), want: 56},
{name: "uint", input: uint(78), want: 78},
{name: "uint64", input: uint64(90), want: 90},
{name: "uint32", input: uint32(21), want: 21},
{name: "string valid", input: "1234", want: 1234},
{name: "string invalid", input: "12x", want: 0},
{name: "default unsupported", input: true, want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := int64Claim(tc.input)
if got != tc.want {
t.Fatalf("expected %d, got %d", tc.want, got)
}
})
}
}
func TestAuthService_truncate(t *testing.T) {
t.Run("max less than or equal zero returns original", func(t *testing.T) {
in := "abcdef"
if got := truncate(in, 0); got != in {
t.Fatalf("expected %q, got %q", in, got)
}
})
t.Run("value shorter than max returns original", func(t *testing.T) {
in := "abc"
if got := truncate(in, 5); got != in {
t.Fatalf("expected %q, got %q", in, got)
}
})
t.Run("value longer than max is truncated", func(t *testing.T) {
if got := truncate("abcdef", 3); got != "abc" {
t.Fatalf("expected %q, got %q", "abc", got)
}
})
}
func TestAuthService_sleepRemaining(t *testing.T) {
t.Run("min less than or equal zero returns quickly", func(t *testing.T) {
start := time.Now()
sleepRemaining(start, 0)
if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
t.Fatalf("expected quick return, elapsed=%s", elapsed)
}
})
t.Run("elapsed already exceeds min so no sleep", func(t *testing.T) {
start := time.Now().Add(-30 * time.Millisecond)
before := time.Now()
sleepRemaining(start, 10*time.Millisecond)
if elapsed := time.Since(before); elapsed > 20*time.Millisecond {
t.Fatalf("expected no additional sleep, elapsed=%s", elapsed)
}
})
t.Run("sleeps remaining duration when elapsed below min", func(t *testing.T) {
start := time.Now()
min := 15 * time.Millisecond
sleepRemaining(start, min)
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
t.Fatalf("expected function to sleep, elapsed=%s", elapsed)
}
})
}
func TestAuthService_shortEmailHash(t *testing.T) {
a := shortEmailHash(" User@Example.com ")
b := shortEmailHash("user@example.com")
if len(a) != 12 {
t.Fatalf("expected short email hash length 12, got %d", len(a))
}
if a != b {
t.Fatalf("expected normalized email hash to be equal, got %q vs %q", a, b)
}
}
func TestAuthService_sanitizeIP(t *testing.T) {
t.Run("encrypts ipv4 and can be decrypted", func(t *testing.T) {
protector := newIPSecretProtector("pepper")
got1 := sanitizeIP("127.0.0.1", protector)
got2 := sanitizeIP("127.0.0.1", protector)
if got1 == got2 {
t.Fatalf("expected randomized ciphertext, got equal values %q", got1)
}
if got1 == "127.0.0.1" {
t.Fatalf("expected ciphertext, got plaintext %q", got1)
}
if plain := decryptStoredIP(got1, protector); plain != "127.0.0.1" {
t.Fatalf("expected decrypted ipv4 value, got %q", plain)
}
})
t.Run("normalizes ipv6 before encryption", func(t *testing.T) {
protector := newIPSecretProtector("pepper")
got1 := sanitizeIP("2001:0db8:0000:0000:0000:0000:0000:0001", protector)
got2 := sanitizeIP("2001:db8::1", protector)
if plain := decryptStoredIP(got1, protector); plain != "2001:db8::1" {
t.Fatalf("expected canonical decrypted ipv6 value, got %q", plain)
}
if plain := decryptStoredIP(got2, protector); plain != "2001:db8::1" {
t.Fatalf("expected canonical decrypted ipv6 value, got %q", plain)
}
})
t.Run("wrong key cannot decrypt", func(t *testing.T) {
enc := sanitizeIP("127.0.0.1", newIPSecretProtector("pepper-a"))
if plain := decryptStoredIP(enc, newIPSecretProtector("pepper-b")); plain == "127.0.0.1" {
t.Fatalf("expected decrypt with wrong key to fail")
}
})
t.Run("encrypts invalid ip input after truncation", func(t *testing.T) {
raw := strings.Repeat("a", 60)
protector := newIPSecretProtector("pepper")
enc := sanitizeIP(raw, protector)
plain := decryptStoredIP(enc, protector)
if len(plain) != 45 {
t.Fatalf("expected decrypted/truncated length 45, got %d", len(plain))
}
})
t.Run("empty input returns empty", func(t *testing.T) {
if got := sanitizeIP(" ", newIPSecretProtector("pepper")); got != "" {
t.Fatalf("expected empty result, got %q", got)
}
})
}
func TestAuthService_SecurityPIN_SetVerifyChangeAndToken(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("new protector: %v", err)
}
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
}
cfg := config.AuthConfig{
SecurityPINActionTokenTTL: 5 * time.Minute,
SecurityPINMaxAttempts: 3,
SecurityPINLockDuration: 2 * time.Minute,
}
svc := NewAuthService(repo, roles, tokens, nil, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
if err := svc.SetSecurityPIN(context.Background(), userID, "12a456"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin format error, got %v", err)
}
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
t.Fatalf("set pin: %v", err)
}
if len(repo.users[string(userID)].SecurityPINHash) == 0 {
t.Fatalf("expected hashed pin record to be persisted")
}
if err := svc.SetSecurityPIN(context.Background(), userID, "654321"); !errors.Is(err, ErrSecurityPINAlreadySet) {
t.Fatalf("expected already set error, got %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin on first wrong attempt, got %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin on second wrong attempt, got %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINAttemptLimit) {
t.Fatalf("expected attempt-limit error on third wrong attempt, got %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINLocked) {
t.Fatalf("expected blocked error after max attempts, got %v", err)
}
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
t.Fatalf("clear lock state: %v", err)
}
token, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
if err != nil {
t.Fatalf("verify pin for action: %v", err)
}
if strings.TrimSpace(token) == "" {
t.Fatalf("expected non-empty action token")
}
if err := svc.ValidateSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token); err != nil {
t.Fatalf("validate pin action token: %v", err)
}
if err := svc.ValidateSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token); err != nil {
t.Fatalf("validate pin action token second time: %v", err)
}
sessionBoundToken, err := svc.VerifySecurityPINForActionInSession(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "sid-1")
if err != nil {
t.Fatalf("verify pin in session: %v", err)
}
if err := svc.ValidateSecurityPINActionTokenInSession(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", sessionBoundToken, "sid-1"); err != nil {
t.Fatalf("validate session-bound token: %v", err)
}
if err := svc.ValidateSecurityPINActionTokenInSession(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", sessionBoundToken, "sid-2"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action for mismatched session, got %v", err)
}
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "428fdf48-3f99-46ea-bc47-8fb2e312be08", token); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action for mismatched action, got %v", err)
}
token2, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")
if err != nil {
t.Fatalf("verify pin second token: %v", err)
}
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token2); err != nil {
t.Fatalf("consume pin action token: %v", err)
}
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token2); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected token to be one-time use, got %v", err)
}
if err := svc.ChangeSecurityPIN(context.Background(), userID, "111111", "654321"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid current pin, got %v", err)
}
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
t.Fatalf("clear lock state after failed change: %v", err)
}
if err := svc.ChangeSecurityPIN(context.Background(), userID, "123456", "654321"); err != nil {
t.Fatalf("change pin: %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected old pin invalid after change, got %v", err)
}
if err := svc.clearSecurityPINLockState(context.Background(), userID); err != nil {
t.Fatalf("clear lock state after old pin verify: %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "654321", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
t.Fatalf("expected new pin to verify, got %v", err)
}
}
func TestAuthService_ForgotAndResetSecurityPIN(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
queue := &mockEmailQueue{}
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
protector, err := NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatalf("new protector: %v", err)
}
userID := uuidv7.MustBytes()
user := &auth.User{
ID: userID,
Email: "user@example.com",
}
repo.users[string(userID)] = user
repo.usersByMail[user.Email] = user
cfg := config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
SecurityPINResetURL: "https://app.example.com/reset-pin",
SecurityPINResetTTL: 20 * time.Minute,
ForgotSecurityPINCooldown: 2 * time.Minute,
}
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
hash, salt, err := svc.hashPassword("Password123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user.PasswordHash = hash
user.SaltValue = salt
svc.ForgotSecurityPIN(context.Background(), user.Email, "127.0.0.1", "ua-test")
if repo.lastSecurityPINResetToken == nil {
t.Fatalf("expected security pin reset token to be created")
}
if queue.last == nil {
t.Fatalf("expected reset email to be enqueued")
}
rawToken := extractTokenValue(queue.last.Body)
if rawToken == "" {
t.Fatalf("expected token in reset email body")
}
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "12345"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin format error, got %v", err)
}
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "123456"); err != nil {
t.Fatalf("reset security pin: %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
t.Fatalf("expected reset pin to verify, got %v", err)
}
if err := svc.ResetSecurityPIN(context.Background(), rawToken, "password", "Password123!", "", "654321"); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected one-time token invalid after use, got %v", err)
}
countBefore := len(repo.securityPINResetTokens)
svc.ForgotSecurityPIN(context.Background(), "missing@example.com", "127.0.0.1", "ua-test")
if len(repo.securityPINResetTokens) != countBefore {
t.Fatalf("expected no new security pin token for missing account")
}
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:security_pin:cooldown:") {
t.Fatalf("expected security pin cooldown key to be set, got %q", tokens.lastKey)
}
}
func TestAuthService_RequestSecurityPINReset(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
queue := &mockEmailQueue{}
userID := uuidv7.MustBytes()
user := &auth.User{
ID: userID,
Email: "user@example.com",
}
repo.users[string(userID)] = user
repo.usersByMail[user.Email] = user
cfg := config.AuthConfig{
SecurityPINResetURL: "https://app.example.com/reset-pin",
SecurityPINResetTTL: 20 * time.Minute,
ForgotSecurityPINCooldown: 2 * time.Minute,
SecurityPINMaxAttempts: 5,
SecurityPINLockDuration: 0,
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
}
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
hash, salt, err := svc.hashPassword("Password123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
user.PasswordHash = hash
user.SaltValue = salt
tokens.store[securityPINLockKey(userID)] = []byte("1")
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); err != nil {
t.Fatalf("request security pin reset: %v", err)
}
if queue.last == nil {
t.Fatalf("expected reset email to be enqueued")
}
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); !errors.Is(err, ErrSecurityPINResetRatelimit) {
t.Fatalf("expected cooldown rate-limit error, got %v", err)
}
delete(tokens.store, forgotSecurityPINCooldownKey(user.Email))
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "wrong-password", "", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidPINResetPassword) {
t.Fatalf("expected invalid password error, got %v", err)
}
delete(tokens.store, securityPINLockKey(userID))
if err := svc.RequestSecurityPINReset(context.Background(), userID, "password", "Password123!", "", "127.0.0.1", "ua"); !errors.Is(err, ErrSecurityPINNotBlocked) {
t.Fatalf("expected not blocked error, got %v", err)
}
}
func TestAuthService_MicrosoftReauthPINReset(t *testing.T) {
repo := newMockAuthRepo()
roles := newMockRoleRepo()
tokens := newMockTokenStore()
queue := &mockEmailQueue{}
sso := &mockSSO{claims: MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
userRoleID := uuidv7.MustBytes()
roles.rolesByName["user"] = &auth.Role{ID: userRoleID, Name: "user"}
cfg := config.AuthConfig{
SecurityPINResetURL: "https://app.example.com/reset-pin",
SecurityPINResetTTL: 20 * time.Minute,
ForgotSecurityPINCooldown: 2 * time.Minute,
SecurityPINMaxAttempts: 5,
SecurityPINLockDuration: 0,
SSOStateTTL: time.Minute,
}
svc := NewAuthService(repo, roles, tokens, queue, AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
identity := &auth.UserIdentity{
ID: uuidv7.MustBytes(),
UserID: uuidv7.MustBytes(),
Provider: "microsoft",
ProviderSubject: "sub",
Email: "user@example.com",
}
if err := repo.CreateUser(context.Background(), &auth.User{
ID: identity.UserID,
Email: "user@example.com",
Timezone: tzpkg.WithDefault(""),
RoleID: userRoleID,
}); err != nil {
t.Fatalf("create user: %v", err)
}
_ = repo.CreateIdentity(context.Background(), identity)
tokens.store[securityPINLockKey(identity.UserID)] = []byte("1")
authURL, err := svc.BeginMicrosoftReauth(context.Background(), identity.UserID, microsoftReauthPurposePINResetRequest)
if err != nil {
t.Fatalf("begin reauth: %v", err)
}
parsedURL, parseErr := url.Parse(authURL)
if parseErr != nil {
t.Fatalf("parse auth url: %v", parseErr)
}
state := parsedURL.Query().Get("state")
reauthToken, purpose, err := svc.CompleteMicrosoftReauth(context.Background(), "code", state)
if err != nil {
t.Fatalf("complete reauth: %v", err)
}
if purpose != microsoftReauthPurposePINResetRequest {
t.Fatalf("unexpected purpose: %s", purpose)
}
if err := svc.RequestSecurityPINReset(context.Background(), identity.UserID, "microsoft", "", reauthToken, "127.0.0.1", "ua"); err != nil {
t.Fatalf("request pin reset with microsoft: %v", err)
}
if queue.last == nil {
t.Fatalf("expected reset email to be enqueued")
}
authURL, err = svc.BeginMicrosoftReauth(context.Background(), identity.UserID, microsoftReauthPurposePINResetRequest)
if err != nil {
t.Fatalf("begin pin reset direct reauth: %v", err)
}
parsedURL, parseErr = url.Parse(authURL)
if parseErr != nil {
t.Fatalf("parse auth url: %v", parseErr)
}
state = parsedURL.Query().Get("state")
reauthToken, purpose, err = svc.CompleteMicrosoftReauth(context.Background(), "code", state)
if err != nil {
t.Fatalf("complete pin reset direct reauth: %v", err)
}
if purpose != microsoftReauthPurposePINResetRequest {
t.Fatalf("unexpected direct reset purpose: %s", purpose)
}
if err := svc.ResetSecurityPIN(context.Background(), "", "microsoft", "", reauthToken, "123456"); err != nil {
t.Fatalf("reset pin with microsoft reauth: %v", err)
}
if _, err := svc.VerifySecurityPINForAction(context.Background(), identity.UserID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
t.Fatalf("expected microsoft reset pin to verify, got %v", err)
}
}
func TestAuthService_ConsumeSecurityPINActionToken_Branches(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
userID := uuidv7.MustBytes()
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "x"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid pin action when token store is nil, got %v", err)
}
tokens := newMockTokenStore()
svc = NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ConsumeSecurityPINActionToken(context.Background(), []byte("short"), "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "x"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid user id error, got %v", err)
}
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, " ", "x"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid action error, got %v", err)
}
if err := svc.ConsumeSecurityPINActionToken(context.Background(), userID, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", ""); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected invalid token error, got %v", err)
}
t.Run("session mismatch does not consume token", func(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SecurityPINActionTokenTTL: time.Minute,
}})
uid := uuidv7.MustBytes()
pinHash, err := svc.hashSecurityPIN("123456")
if err != nil {
t.Fatalf("hash pin: %v", err)
}
repo.users[string(uid)] = &auth.User{ID: uid, SecurityPINHash: pinHash}
token, err := svc.VerifySecurityPINForActionInSession(context.Background(), uid, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", "sid-1")
if err != nil {
t.Fatalf("issue action token: %v", err)
}
if err := svc.ConsumeSecurityPINActionTokenInSession(context.Background(), uid, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token, "sid-2"); !errors.Is(err, ErrInvalidPINAction) {
t.Fatalf("expected session mismatch invalid action, got %v", err)
}
if err := svc.ConsumeSecurityPINActionTokenInSession(context.Background(), uid, "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74", token, "sid-1"); err != nil {
t.Fatalf("expected token still valid after mismatch, got %v", err)
}
})
}
func TestAuthService_buildSecurityPINResetEmail(t *testing.T) {
resolver := authEmailBrandingResolverMock{
branding: AuthEmailBranding{
CompanyName: "Acme Air Rescue",
LogoContentID: "branding-email-logo",
LogoAttachment: &queue.EmailAttachment{
Filename: "email-logo.png",
ContentType: "image/png",
ContentID: "branding-email-logo",
Content: []byte("png"),
Inline: true,
},
},
}
email := buildSecurityPINResetEmail(context.Background(), resolver, "Marco", "https://app.example.com/reset-pin?token=abc", 20*time.Minute)
if !strings.Contains(email.Subject, "security PIN") {
t.Fatalf("unexpected subject: %q", email.Subject)
}
if !strings.Contains(email.TextBody, "6-digit PIN") {
t.Fatalf("expected 6-digit instruction in text body")
}
if !strings.Contains(email.HTMLBody, "Reset Security PIN") {
t.Fatalf("expected reset button text in html body")
}
if !strings.Contains(email.HTMLBody, "cid:"+resolver.branding.LogoContentID) {
t.Fatalf("expected cid logo reference in html body")
}
if !strings.Contains(email.HTMLBody, resolver.branding.CompanyName) {
t.Fatalf("expected branding company name in html body")
}
if len(email.Attachments) != 1 || !email.Attachments[0].Inline || email.Attachments[0].ContentID != resolver.branding.LogoContentID {
t.Fatalf("expected inline logo attachment, got %#v", email.Attachments)
}
}
func TestAuthService_validateSecurityPIN(t *testing.T) {
if err := validateSecurityPIN("123456"); err != nil {
t.Fatalf("expected valid pin, got %v", err)
}
if err := validateSecurityPIN("12345"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid length error, got %v", err)
}
if err := validateSecurityPIN("12345a"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid char error, got %v", err)
}
}
func TestAuthService_SecurityPIN_Branches(t *testing.T) {
t.Run("set pin invalid user id", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.SetSecurityPIN(context.Background(), []byte("short"), "123456"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin error, got %v", err)
}
})
t.Run("set pin without protector", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
t.Fatalf("expected set pin to work without protector, got %v", err)
}
if len(repo.users[string(userID)].SecurityPINHash) == 0 {
t.Fatalf("expected hashed pin to be persisted")
}
})
t.Run("set pin user not found", func(t *testing.T) {
p := &errorProtector{}
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{}})
if err := svc.SetSecurityPIN(context.Background(), uuidv7.MustBytes(), "123456"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token for missing user, got %v", err)
}
})
t.Run("set pin stores hashed record", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
t.Fatalf("set pin failed: %v", err)
}
raw := repo.users[string(userID)].SecurityPINHash
if len(raw) == 0 {
t.Fatalf("expected pin record to be stored")
}
if bytes.Equal(raw, []byte("123456")) {
t.Fatalf("pin must not be stored in plaintext")
}
parts := strings.Split(string(raw), "$")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
t.Fatalf("expected hash record format <salt_b64>$<hash_b64>, got %q", string(raw))
}
})
t.Run("verify pin supports legacy v1 hash record", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
PasswordPepper: "pepper",
}})
hash, salt, err := svc.hashPassword("123456")
if err != nil {
t.Fatalf("hash password: %v", err)
}
repo.users[string(userID)].SecurityPINHash = []byte(
"v1$" + base64.RawURLEncoding.EncodeToString(salt) + "$" + base64.RawURLEncoding.EncodeToString(hash),
)
if err := svc.verifySecurityPIN(context.Background(), userID, "123456"); err != nil {
t.Fatalf("expected legacy v1 pin hash to verify, got %v", err)
}
})
t.Run("change pin invalid user id", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ChangeSecurityPIN(context.Background(), []byte("short"), "123456", "654321"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid pin error, got %v", err)
}
})
t.Run("change pin new pin format invalid", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ChangeSecurityPIN(context.Background(), uuidv7.MustBytes(), "123456", "xx"); !errors.Is(err, ErrInvalidSecurityPIN) {
t.Fatalf("expected invalid new pin format, got %v", err)
}
})
t.Run("verify pin not set", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
p := &errorProtector{}
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
SecurityPINActionTokenTTL: time.Minute,
}})
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, ErrSecurityPINNotSet) {
t.Fatalf("expected pin not set error, got %v", err)
}
})
t.Run("verify pin decrypt error", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
SecurityPINHash: []byte("cipher"),
}
p := &errorProtector{decryptErr: errors.New("decrypt failed")}
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
SecurityPINActionTokenTTL: time.Minute,
}})
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, p.decryptErr) {
t.Fatalf("expected decrypt error, got %v", err)
}
})
t.Run("verify pin missing action", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
SecurityPINHash: []byte("cipher"),
}
p := &errorProtector{}
svc := NewAuthService(repo, newMockRoleRepo(), newMockTokenStore(), nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
SecurityPINActionTokenTTL: time.Minute,
}})
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", " "); err == nil {
t.Fatalf("expected missing action error")
}
})
t.Run("verify pin token set error", func(t *testing.T) {
repo := newMockAuthRepo()
userID := uuidv7.MustBytes()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
SecurityPINHash: []byte("cipher"),
}
p := &errorProtector{}
tokens := newMockTokenStore()
tokens.setErr = errors.New("set failed")
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
SecurityPINActionTokenTTL: time.Minute,
}})
if _, err := svc.VerifySecurityPINForAction(context.Background(), userID, "123456", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); !errors.Is(err, tokens.setErr) {
t.Fatalf("expected token set error, got %v", err)
}
})
}
func TestAuthService_ForgotSecurityPIN_Branches(t *testing.T) {
t.Run("empty email", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINMinDuration: 0,
}})
svc.ForgotSecurityPIN(context.Background(), " ", "127.0.0.1", "ua")
})
t.Run("cooldown active", func(t *testing.T) {
repo := newMockAuthRepo()
tokens := newMockTokenStore()
email := "user@example.com"
tokens.store[forgotSecurityPINCooldownKey(email)] = []byte("1")
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINCooldown: 2 * time.Minute,
}})
svc.ForgotSecurityPIN(context.Background(), email, "127.0.0.1", "ua")
if repo.lastSecurityPINResetToken != nil {
t.Fatalf("expected no token created while cooldown is active")
}
})
t.Run("lookup error still marks cooldown", func(t *testing.T) {
repo := newMockAuthRepo()
repo.getByMailErr = errors.New("db down")
tokens := newMockTokenStore()
svc := NewAuthService(repo, newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINCooldown: 2 * time.Minute,
}})
svc.ForgotSecurityPIN(context.Background(), "user@example.com", "127.0.0.1", "ua")
if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:security_pin:cooldown:") {
t.Fatalf("expected cooldown key to be marked, got %q", tokens.lastKey)
}
})
}
func TestAuthService_createAndSendSecurityPINReset_Branches(t *testing.T) {
user := &auth.User{
ID: uuidv7.MustBytes(),
Email: "user@example.com",
}
baseCfg := config.AuthConfig{
SecurityPINResetURL: "https://app.example.com/reset-pin",
SecurityPINResetTTL: 20 * time.Minute,
}
t.Run("skip when reset url is empty", func(t *testing.T) {
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SecurityPINResetURL: "",
SecurityPINResetTTL: 20 * time.Minute,
}})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error when url is empty, got %v", err)
}
})
t.Run("random token generation error", func(t *testing.T) {
orig := rand.Reader
rand.Reader = errReader{}
defer func() { rand.Reader = orig }()
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected random token error, got %v", err)
}
})
t.Run("invalidate active tokens error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.invalidatePINResetTokensErr = errors.New("invalidate failed")
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.invalidatePINResetTokensErr) {
t.Fatalf("expected invalidate error, got %v", err)
}
})
t.Run("create token error", func(t *testing.T) {
repo := newMockAuthRepo()
repo.createPINResetTokenErr = errors.New("create pin reset failed")
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, repo.createPINResetTokenErr) {
t.Fatalf("expected create token error, got %v", err)
}
})
t.Run("invalid reset url fails before token is persisted", func(t *testing.T) {
repo := newMockAuthRepo()
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
SecurityPINResetURL: "http://",
SecurityPINResetTTL: 20 * time.Minute,
}})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err == nil {
t.Fatalf("expected invalid security pin reset url error")
}
if repo.lastSecurityPINResetToken != nil {
t.Fatalf("token should not be persisted when reset url is invalid")
}
})
t.Run("queue enqueue error", func(t *testing.T) {
repo := newMockAuthRepo()
queue := &mockEmailQueue{err: errors.New("queue failed")}
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); !errors.Is(err, queue.err) {
t.Fatalf("expected queue error, got %v", err)
}
})
t.Run("queue success", func(t *testing.T) {
repo := newMockAuthRepo()
queue := &mockEmailQueue{}
svc := NewAuthService(repo, newMockRoleRepo(), nil, queue, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if queue.last == nil {
t.Fatalf("expected security pin reset email to be queued")
}
if strings.TrimSpace(queue.last.HTMLBody) == "" {
t.Fatalf("expected non-empty html body in queued security pin reset email")
}
})
t.Run("no queue and no mailer returns nil", func(t *testing.T) {
repo := newMockAuthRepo()
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: baseCfg})
if err := svc.createAndSendSecurityPINReset(context.Background(), user, "127.0.0.1", "ua"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.lastSecurityPINResetToken == nil {
t.Fatalf("expected security pin reset token to be persisted")
}
})
}
func TestAuthService_ResetSecurityPIN_Branches(t *testing.T) {
userID := uuidv7.MustBytes()
repo := newMockAuthRepo()
repo.users[string(userID)] = &auth.User{
ID: userID,
Email: "user@example.com",
PasswordHash: []byte("hash"),
SaltValue: []byte("salt"),
}
repo.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
ID: uuidv7.MustBytes(),
UserID: userID,
TokenHash: hashToken("token"),
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
t.Run("missing token", func(t *testing.T) {
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ResetSecurityPIN(context.Background(), " ", "password", "Password123!", "", "123456"); !errors.Is(err, ErrInvalidResetLink) {
t.Fatalf("expected invalid reset link, got %v", err)
}
})
t.Run("missing password", func(t *testing.T) {
svc := NewAuthService(repo, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", " ", "", "123456"); !errors.Is(err, ErrInvalidPINResetPassword) {
t.Fatalf("expected invalid pin reset password, got %v", err)
}
})
t.Run("reset pin works without protector", func(t *testing.T) {
password := "Password123!"
repo2 := newMockAuthRepo()
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
repo2.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
ID: uuidv7.MustBytes(),
UserID: userID,
TokenHash: hashToken("token"),
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
svc := NewAuthService(repo2, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
}})
hash, salt, err := svc.hashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
repo2.users[string(userID)].PasswordHash = hash
repo2.users[string(userID)].SaltValue = salt
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", password, "", "123456"); err != nil {
t.Fatalf("expected reset pin success without protector, got %v", err)
}
})
t.Run("microsoft reset works without reset token", func(t *testing.T) {
repo2 := newMockAuthRepo()
tokens2 := newMockTokenStore()
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
token := "reauth-microsoft"
tokens2.store[microsoftReauthKey(token)] = []byte(bytesToString(userID) + "|" + microsoftReauthPurposePINResetRequest)
svc := NewAuthService(repo2, newMockRoleRepo(), tokens2, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
if err := svc.ResetSecurityPIN(context.Background(), "", "microsoft", "", token, "123456"); err != nil {
t.Fatalf("expected microsoft reset pin success without reset token, got %v", err)
}
if len(repo2.users[string(userID)].SecurityPINHash) == 0 {
t.Fatalf("expected security pin hash to be updated")
}
})
t.Run("consume error", func(t *testing.T) {
password := "Password123!"
p := &errorProtector{}
repo2 := newMockAuthRepo()
repo2.users[string(userID)] = &auth.User{ID: userID, Email: "user@example.com"}
repo2.securityPINResetTokens[string(hashToken("token"))] = &auth.SecurityPINResetToken{
ID: uuidv7.MustBytes(),
UserID: userID,
TokenHash: hashToken("token"),
ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
}
repo2.consumePINResetErr = errors.New("consume failed")
svc := NewAuthService(repo2, newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: p, Config: config.AuthConfig{
PasswordPepper: "pepper",
HashTime: 1,
HashMemoryKB: 64 * 1024,
HashThreads: 4,
HashKeyLen: 32,
}})
hash, salt, err := svc.hashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
repo2.users[string(userID)].PasswordHash = hash
repo2.users[string(userID)].SaltValue = salt
if err := svc.ResetSecurityPIN(context.Background(), "token", "password", password, "", "123456"); !errors.Is(err, repo2.consumePINResetErr) {
t.Fatalf("expected consume error, got %v", err)
}
})
}
func TestAuthService_ForgotSecurityPINCooldownHelpers(t *testing.T) {
svcNoTokens := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), nil, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINCooldown: 2 * time.Minute,
}})
if svcNoTokens.isForgotSecurityPINCooldownActive(context.Background(), "k") {
t.Fatalf("expected inactive cooldown when token store is nil")
}
if err := svcNoTokens.markForgotSecurityPINCooldown(context.Background(), "k"); err != nil {
t.Fatalf("expected nil when token store is nil, got %v", err)
}
tokens := newMockTokenStore()
svcDisabled := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINCooldown: 0,
}})
if svcDisabled.isForgotSecurityPINCooldownActive(context.Background(), "k") {
t.Fatalf("expected inactive cooldown when cooldown disabled")
}
if err := svcDisabled.markForgotSecurityPINCooldown(context.Background(), "k"); err != nil {
t.Fatalf("expected nil when cooldown disabled, got %v", err)
}
svc := NewAuthService(newMockAuthRepo(), newMockRoleRepo(), tokens, nil, AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
ForgotSecurityPINCooldown: 2 * time.Minute,
}})
if err := svc.markForgotSecurityPINCooldown(context.Background(), "k1"); err != nil {
t.Fatalf("mark cooldown: %v", err)
}
if !svc.isForgotSecurityPINCooldownActive(context.Background(), "k1") {
t.Fatalf("expected cooldown active after mark")
}
}