package service import ( "context" "errors" "net/http" "testing" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/model" "github.com/dhao2001/mygo/internal/repository" ) func setupAuthService(t *testing.T) *AuthService { svc, _ := setupAuthServiceWithRepos(t) return svc } // setupAuthServiceWithRepos creates an AuthService and returns the UserRepository // for tests that need direct repo access (e.g., soft-deleting users). func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open db: %v", err) } if err := db.AutoMigrate(&model.User{}, &model.Session{}, &model.Credential{}); err != nil { t.Fatalf("migrate: %v", err) } userRepo := repository.NewUserRepository(db) sessionRepo := repository.NewSessionRepository(db) credentialRepo := repository.NewCredentialRepository(db) svc := NewAuthService( userRepo, sessionRepo, credentialRepo, []byte("test-secret"), 15*time.Minute, 7*24*time.Hour, ) return svc, userRepo } func TestAuthService_Register(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } if user.ID == "" { t.Fatal("user ID is empty") } if user.Username != "alice" { t.Errorf("Username = %q, want %q", user.Username, "alice") } if user.PasswordHash == "password123" { t.Fatal("password should be hashed") } } func TestAuthService_RegisterDuplicate(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } _, err = svc.Register(ctx, "alice", "alice2@example.com", "password123") if err == nil { t.Fatal("expected error for duplicate username, got nil") } } func TestAuthService_RegisterEmptyFields(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "", "alice@example.com", "password") if err == nil { t.Fatal("expected error for empty username, got nil") } } func TestAuthService_Login(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } if pair.AccessToken == "" { t.Fatal("access token is empty") } if pair.RefreshToken == "" { t.Fatal("refresh token is empty") } } func TestAuthService_LoginWrongPassword(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } _, err = svc.Login(ctx, "alice@example.com", "wrongpassword") if err == nil { t.Fatal("expected error for wrong password, got nil") } } func TestAuthService_LoginNonexistentEmail(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Login(ctx, "nonexistent@example.com", "password") if err == nil { t.Fatal("expected error for nonexistent email, got nil") } } func TestAuthService_Refresh(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } newPair, err := svc.Refresh(ctx, pair.RefreshToken) if err != nil { t.Fatalf("Refresh = %v", err) } if newPair.AccessToken == "" { t.Fatal("new access token is empty") } if newPair.RefreshToken == "" { t.Fatal("new refresh token is empty") } if newPair.RefreshToken == pair.RefreshToken { t.Fatal("refresh token should be rotated") } } func TestAuthService_RefreshSingleUse(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } _, err = svc.Refresh(ctx, pair.RefreshToken) if err != nil { t.Fatalf("first Refresh = %v", err) } _, err = svc.Refresh(ctx, pair.RefreshToken) if err == nil { t.Fatal("second refresh with same token should fail") } } func TestAuthService_Logout(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } if err := svc.Logout(ctx, pair.RefreshToken); err != nil { t.Fatalf("Logout = %v", err) } _, err = svc.Refresh(ctx, pair.RefreshToken) if err == nil { t.Fatal("refresh should fail after logout") } } func TestAuthService_CreatePasskey(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } // Extract userID from access token claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret")) if err != nil { t.Fatalf("ParseToken = %v", err) } // Import auth for claims access // Already using auth above pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") if err != nil { t.Fatalf("CreatePasskey = %v", err) } if pk.ID == "" { t.Fatal("passkey ID is empty") } if pk.Raw == "" { t.Fatal("raw token is empty") } if pk.Label != "My Phone" { t.Errorf("Label = %q, want %q", pk.Label, "My Phone") } } func TestAuthService_LoginWithPasskey(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret")) if err != nil { t.Fatalf("ParseToken = %v", err) } pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") if err != nil { t.Fatalf("CreatePasskey = %v", err) } loginPair, err := svc.LoginWithPasskey(ctx, pk.Raw) if err != nil { t.Fatalf("LoginWithPasskey = %v", err) } if loginPair.AccessToken == "" { t.Fatal("access token is empty") } } func TestAuthService_LoginWithPasskeyInvalidFormat(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.LoginWithPasskey(ctx, "not-a-mygo-token") if err == nil { t.Fatal("expected error for invalid passkey format, got nil") } } func TestAuthService_RevokePasskey(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, _ := svc.Login(ctx, "alice@example.com", "password123") claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret")) pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") if err != nil { t.Fatalf("CreatePasskey = %v", err) } if err := svc.RevokePasskey(ctx, claims.UserID, pk.ID); err != nil { t.Fatalf("RevokePasskey = %v", err) } _, err = svc.LoginWithPasskey(ctx, pk.Raw) if err == nil { t.Fatal("login with revoked passkey should fail") } } func TestAuthService_RevokePasskeyNotOwner(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } _, err = svc.Register(ctx, "bob", "bob@example.com", "password456") if err != nil { t.Fatalf("Register = %v", err) } pair, _ := svc.Login(ctx, "alice@example.com", "password123") claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret")) pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") if err != nil { t.Fatalf("CreatePasskey = %v", err) } pairBob, _ := svc.Login(ctx, "bob@example.com", "password456") claimsBob, _ := auth.ParseToken(pairBob.AccessToken, []byte("test-secret")) err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID) var ae *model.AppError if !errors.As(err, &ae) || ae.Status != http.StatusForbidden { t.Fatalf("expected AppError 403 Forbidden, got %v", err) } } func TestAuthService_ListPasskeys(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, _ := svc.Login(ctx, "alice@example.com", "password123") claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret")) _, err = svc.CreatePasskey(ctx, claims.UserID, "Phone") if err != nil { t.Fatalf("CreatePasskey 1 = %v", err) } _, err = svc.CreatePasskey(ctx, claims.UserID, "Laptop") if err != nil { t.Fatalf("CreatePasskey 2 = %v", err) } passkeys, err := svc.ListPasskeys(ctx, claims.UserID) if err != nil { t.Fatalf("ListPasskeys = %v", err) } if len(passkeys) != 2 { t.Errorf("len(passkeys) = %d, want 2", len(passkeys)) } } func TestAuthService_RefreshWithInvalidToken(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Refresh(ctx, "not-a-valid-token") if err == nil { t.Fatal("expected error for invalid refresh token, got nil") } } func TestAuthService_RefreshWithAccessToken(t *testing.T) { svc := setupAuthService(t) ctx := context.Background() _, err := svc.Register(ctx, "testuser", "testuser@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "testuser@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } // Attempt to use the access token as a refresh token _, err = svc.Refresh(ctx, pair.AccessToken) if err == nil { t.Fatal("expected error when using access token for refresh, got nil") } } func TestAuthService_LoginDisabledUser(t *testing.T) { svc, userRepo := setupAuthServiceWithRepos(t) ctx := context.Background() user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } if err := userRepo.Delete(ctx, user.ID); err != nil { t.Fatalf("Delete = %v", err) } _, err = svc.Login(ctx, "alice@example.com", "password123") if err == nil { t.Fatal("expected error for disabled user login, got nil") } var ae *model.AppError if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } if ae.Status != http.StatusUnauthorized { t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) } if ae.Message != "invalid email or password" { t.Errorf("message = %q, want %q", ae.Message, "invalid email or password") } } func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) { svc, userRepo := setupAuthServiceWithRepos(t) ctx := context.Background() user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } // Get error for wrong password (active user) _, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword") // Soft-delete user, then try to login if err := userRepo.Delete(ctx, user.ID); err != nil { t.Fatalf("Delete = %v", err) } _, disabledErr := svc.Login(ctx, "alice@example.com", "password123") // Both errors should have the same message (no user enumeration) if disabledErr == nil { t.Fatal("expected error for disabled user login, got nil") } var aeDisabled *model.AppError if !errors.As(disabledErr, &aeDisabled) { t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr) } var aeWrongPw *model.AppError if !errors.As(wrongPwErr, &aeWrongPw) { t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr) } if aeDisabled.Message != aeWrongPw.Message { t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message) } if aeDisabled.Message != "invalid email or password" { t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password") } } func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) { svc, userRepo := setupAuthServiceWithRepos(t) ctx := context.Background() user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret")) if err != nil { t.Fatalf("ParseToken = %v", err) } pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone") if err != nil { t.Fatalf("CreatePasskey = %v", err) } // Soft-delete the user if err := userRepo.Delete(ctx, user.ID); err != nil { t.Fatalf("Delete = %v", err) } _, err = svc.LoginWithPasskey(ctx, pk.Raw) if err == nil { t.Fatal("expected error for disabled user passkey login, got nil") } var ae *model.AppError if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } if ae.Status != http.StatusUnauthorized { t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) } if ae.Message != "invalid passkey" { t.Errorf("message = %q, want %q", ae.Message, "invalid passkey") } } func TestAuthService_RefreshDisabledUser(t *testing.T) { svc, userRepo := setupAuthServiceWithRepos(t) ctx := context.Background() user, err := svc.Register(ctx, "alice", "alice@example.com", "password123") if err != nil { t.Fatalf("Register = %v", err) } pair, err := svc.Login(ctx, "alice@example.com", "password123") if err != nil { t.Fatalf("Login = %v", err) } // Soft-delete the user if err := userRepo.Delete(ctx, user.ID); err != nil { t.Fatalf("Delete = %v", err) } _, err = svc.Refresh(ctx, pair.RefreshToken) if err == nil { t.Fatal("expected error for disabled user refresh, got nil") } var ae *model.AppError if !errors.As(err, &ae) { t.Fatalf("expected AppError, got %T: %v", err, err) } if ae.Status != http.StatusUnauthorized { t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized) } if ae.Message != "invalid token" { t.Errorf("message = %q, want %q", ae.Message, "invalid token") } }