package e2e import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "testing" "time" "goyco/internal/config" "goyco/internal/repositories" "goyco/internal/services" "goyco/internal/testutils" "github.com/golang-jwt/jwt/v5" ) func TestE2E_APIRegistration(t *testing.T) { ctx := setupTestContext(t) t.Run("api_registration_flow", func(t *testing.T) { var createdUser *TestUser t.Cleanup(func() { if createdUser != nil { ctx.server.UserRepo.Delete(createdUser.ID) } }) tempUser := ctx.createUserWithCleanup(t, "temp", "TempPass123!") tempAuthClient := ctx.loginUser(t, tempUser.Username, tempUser.Password) ctx.server.EmailSender.Reset() newUsername := uniqueUsername(t, "newuser") newEmail := uniqueEmail(t, "newuser") registerResp := tempAuthClient.RegisterUser(t, newUsername, newEmail, "NewPass123!") if !registerResp.Success { t.Errorf("Expected registration to be successful, got failure: %s", registerResp.Message) } ctx.loginExpectStatus(t, newUsername, "NewPass123!", http.StatusForbidden) confirmationToken := ctx.server.EmailSender.VerificationToken() if confirmationToken == "" { t.Fatalf("expected registration to trigger verification token") } ctx.confirmEmail(t, confirmationToken) authClient := ctx.loginUser(t, newUsername, "NewPass123!") if authClient.Token == "" { t.Errorf("Expected to be able to login with registered user") } user, err := ctx.server.UserRepo.GetByUsername(newUsername) if err != nil { t.Fatalf("Failed to load registered user: %v", err) } createdUser = &TestUser{ ID: user.ID, Username: user.Username, Email: user.Email, Password: "NewPass123!", EmailVerified: user.EmailVerified, } }) } func TestE2E_PasswordResetFlow(t *testing.T) { ctx := setupTestContext(t) t.Run("password_reset_flow", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "resetuser", "OldPassword123!") originalPassword := createdUser.Password t.Run("request_password_reset", func(t *testing.T) { ctx.server.EmailSender.Reset() statusCode := testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Email, testutils.GenerateTestIP()) if statusCode != http.StatusOK { t.Errorf("Expected password reset request to succeed with status 200, got %d", statusCode) } resetToken := ctx.server.EmailSender.PasswordResetToken() if resetToken == "" { t.Errorf("Expected password reset email to contain a reset token, but token is empty") } }) t.Run("reset_password_with_valid_token", func(t *testing.T) { ctx.server.EmailSender.Reset() statusCode := testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Username, testutils.GenerateTestIP()) if statusCode != http.StatusOK { t.Errorf("Expected password reset request to succeed, got status %d", statusCode) } resetToken := ctx.server.EmailSender.PasswordResetToken() if resetToken == "" { t.Fatalf("Expected password reset email to contain a reset token") } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, resetToken, "NewPassword456!", testutils.GenerateTestIP()) if statusCode != http.StatusOK { t.Errorf("Expected password reset to succeed with status 200, got %d", statusCode) } }) t.Run("login_with_new_password_works", func(t *testing.T) { ctx.loginExpectStatus(t, createdUser.Username, originalPassword, http.StatusUnauthorized) newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword456!") if newAuthClient.Token == "" { t.Errorf("Expected to be able to login with new password, but login failed") } profile := newAuthClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected to access profile with new password, got username '%s'", profile.Data.Username) } }) t.Run("old_password_no_longer_works", func(t *testing.T) { statusCode := retryOnRateLimit(t, 3, func() int { return ctx.loginExpectStatus(t, createdUser.Username, originalPassword, http.StatusUnauthorized) }) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping rest of old password test: rate limited after retries") } newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword456!") if newAuthClient.Token == "" { t.Errorf("Expected to still be able to login with new password after checking old password") } }) t.Run("expired_invalid_tokens_rejected", func(t *testing.T) { statusCode := testutils.ResetPassword(t, ctx.client, ctx.baseURL, "", "AnotherPassword789!", testutils.GenerateTestIP()) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected empty token to be rejected with status 400 or 429, got %d", statusCode) } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, "invalid-token-12345", "AnotherPassword789!", testutils.GenerateTestIP()) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected invalid token to be rejected with status 400 or 429, got %d", statusCode) } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, "not-a-valid-token", "AnotherPassword789!", testutils.GenerateTestIP()) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected malformed token to be rejected with status 400 or 429, got %d", statusCode) } ctx.server.EmailSender.Reset() resetStatus := retryOnRateLimit(t, 3, func() int { return testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Email, testutils.GenerateTestIP()) }) if resetStatus == http.StatusTooManyRequests { t.Skip("Skipping token reuse test: rate limited after retries") } validToken := ctx.server.EmailSender.PasswordResetToken() if validToken == "" { t.Fatalf("Expected password reset token but got empty") } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, validToken, "AnotherPassword789!", testutils.GenerateTestIP()) if statusCode != http.StatusOK { t.Errorf("Expected valid token to work, got status %d", statusCode) } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, validToken, "YetAnotherPassword000!", testutils.GenerateTestIP()) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected used token to be rejected with status 400 or 429, got %d", statusCode) } }) t.Run("password_reset_by_username", func(t *testing.T) { ctx.server.EmailSender.Reset() statusCode := retryOnRateLimit(t, 3, func() int { return testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Username, testutils.GenerateTestIP()) }) if statusCode != http.StatusOK && statusCode != http.StatusTooManyRequests { t.Errorf("Expected password reset request by username to succeed or be rate limited, got status %d", statusCode) } if statusCode == http.StatusTooManyRequests { t.Skip("Skipping username reset test: rate limited after retries") } resetToken := ctx.server.EmailSender.PasswordResetToken() if resetToken == "" { t.Errorf("Expected password reset email to contain a reset token when using username") } statusCode = testutils.ResetPassword(t, ctx.client, ctx.baseURL, resetToken, "FinalPassword999!", testutils.GenerateTestIP()) if statusCode != http.StatusOK { t.Errorf("Expected password reset with username-requested token to succeed, got status %d", statusCode) } newAuthClient := ctx.loginUser(t, createdUser.Username, "FinalPassword999!") if newAuthClient.Token == "" { t.Errorf("Expected to be able to login with password reset via username") } }) t.Run("password_reset_nonexistent_user", func(t *testing.T) { ctx.server.EmailSender.Reset() statusCode := testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, "nonexistent@example.com", testutils.GenerateTestIP()) if statusCode != http.StatusOK && statusCode != http.StatusTooManyRequests { t.Errorf("Expected password reset request for non-existent user to return 200 or 429 (security), got %d", statusCode) } if statusCode == http.StatusOK { resetToken := ctx.server.EmailSender.PasswordResetToken() if resetToken != "" { t.Errorf("Expected no password reset token for non-existent user, but got token: %s", resetToken) } } }) }) } func TestE2E_RefreshTokenFlow(t *testing.T) { ctx := setupTestContext(t) t.Run("refresh_token_flow", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "refreshtokenuser", "Password123!") t.Run("login_get_tokens", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) if authClient.Token == "" { t.Errorf("Expected access token to be set after login, but it's empty") } if authClient.RefreshToken == "" { t.Errorf("Expected refresh token to be set after login, but it's empty") } profile := authClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected to access profile with access token, got username '%s'", profile.Data.Username) } }) t.Run("refresh_access_token", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) originalAccessToken := authClient.Token originalRefreshToken := authClient.RefreshToken time.Sleep(100 * time.Millisecond) newAccessToken, statusCode := authClient.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token to succeed with status 200, got %d", statusCode) } if newAccessToken == "" { t.Errorf("Expected new access token to be returned, but it's empty") } if newAccessToken == originalAccessToken { t.Logf("New access token is identical to original (may occur if generated within same second)") } if authClient.RefreshToken != originalRefreshToken { t.Logf("Refresh token was changed (token rotation), which is acceptable") } profile := authClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected to access profile with new access token, got username '%s'", profile.Data.Username) } oldAuthClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: originalAccessToken, RefreshToken: originalRefreshToken, BaseURL: ctx.baseURL, }, } profile = oldAuthClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected old access token to still work (until expiration), got username '%s'", profile.Data.Username) } }) t.Run("invalid_refresh_token_rejected", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) invalidClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: authClient.Token, RefreshToken: "invalid-refresh-token-12345", BaseURL: ctx.baseURL, }, } _, statusCode := invalidClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized && statusCode != http.StatusBadRequest { t.Errorf("Expected invalid refresh token to be rejected with status 401 or 400, got %d", statusCode) } }) t.Run("empty_refresh_token_rejected", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) emptyClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: authClient.Token, RefreshToken: "", BaseURL: ctx.baseURL, }, } _, statusCode := emptyClient.RefreshAccessToken(t) if statusCode != http.StatusBadRequest && statusCode != http.StatusUnauthorized { t.Errorf("Expected empty refresh token to be rejected with status 400 or 401, got %d", statusCode) } }) t.Run("expired_refresh_token_rejected", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshTokenString := authClient.RefreshToken refreshToken, err := ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshTokenString)) if err != nil { t.Fatalf("Failed to find refresh token in database: %v", err) } refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour) if err := ctx.server.DB.Save(refreshToken).Error; err != nil { t.Fatalf("Failed to expire refresh token: %v", err) } expiredClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: authClient.Token, RefreshToken: refreshTokenString, BaseURL: ctx.baseURL, }, } _, statusCode := expiredClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected expired refresh token to be rejected with status 401, got %d", statusCode) } _, err = ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshTokenString)) if err == nil { t.Errorf("Expected expired refresh token to be deleted from database after failed refresh attempt") } }) t.Run("multiple_refresh_operations", func(t *testing.T) { loginStatus := retryOnRateLimit(t, 3, func() int { return ctx.loginExpectStatus(t, createdUser.Username, createdUser.Password, http.StatusOK) }) if loginStatus == http.StatusTooManyRequests { t.Skip("Skipping multiple refresh operations test: rate limited after retries") } authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshCount := 0 for i := range 3 { var statusCode int var newAccessToken string for attempt := range 3 { newAccessToken, statusCode = authClient.RefreshAccessToken(t, testutils.GenerateTestIP()) if statusCode != http.StatusTooManyRequests { break } if attempt < 2 { time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond) } } if statusCode != http.StatusOK { if statusCode == http.StatusTooManyRequests { t.Logf("Refresh operation %d was rate limited after retries, continuing", i+1) continue } t.Errorf("Expected refresh token operation %d to succeed, got status %d", i+1, statusCode) } else { refreshCount++ } if newAccessToken != "" { profile := authClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected to access profile with refreshed token %d, got username '%s'", i+1, profile.Data.Username) } } } if refreshCount == 0 { t.Skip("Skipping test: all refresh operations were rate limited after retries") return } if authClient.RefreshToken == "" { t.Errorf("Expected refresh token to still be available after multiple refreshes") } }) }) } func TestE2E_TokenRevocationFlow(t *testing.T) { ctx := setupTestContext(t) t.Run("token_revocation_flow", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "revokeuser", "Password123!") t.Run("single_token_revocation", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken1 := authClient.RefreshToken _, statusCode := authClient.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token to work before revocation, got status %d", statusCode) } statusCode = authClient.RevokeToken(t, refreshToken1) if statusCode != http.StatusOK { t.Errorf("Expected token revocation to succeed with status 200, got %d", statusCode) } revokedClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: authClient.Token, RefreshToken: refreshToken1, BaseURL: ctx.baseURL, }, } _, statusCode = revokedClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected revoked refresh token to be rejected with status 401, got %d", statusCode) } }) t.Run("revoked_token_cannot_be_used", func(t *testing.T) { authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken2 := authClient.RefreshToken statusCode := authClient.RevokeToken(t, refreshToken2) if statusCode != http.StatusOK { t.Errorf("Expected token revocation to succeed, got status %d", statusCode) } revokedClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: authClient.Token, RefreshToken: refreshToken2, BaseURL: ctx.baseURL, }, } _, statusCode = revokedClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected revoked refresh token (first attempt) to be rejected with status 401, got %d", statusCode) } _, statusCode = revokedClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected revoked refresh token (second attempt) to be rejected with status 401, got %d", statusCode) } _, err := ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshToken2)) if err == nil { t.Errorf("Expected revoked refresh token to be deleted from database") } }) t.Run("revoke_all_tokens", func(t *testing.T) { authClient1 := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken1 := authClient1.RefreshToken authClient2 := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken2 := authClient2.RefreshToken authClient3 := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken3 := authClient3.RefreshToken _, statusCode := authClient1.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token 1 to work before revocation, got status %d", statusCode) } _, statusCode = authClient2.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token 2 to work before revocation, got status %d", statusCode) } _, statusCode = authClient3.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token 3 to work before revocation, got status %d", statusCode) } statusCode = authClient1.RevokeAllTokens(t) if statusCode != http.StatusOK { t.Errorf("Expected revoke-all to succeed with status 200, got %d", statusCode) } _, statusCode = authClient1.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected refresh token 1 to be rejected after revoke-all, got status %d", statusCode) } _, statusCode = authClient2.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected refresh token 2 to be rejected after revoke-all, got status %d", statusCode) } _, statusCode = authClient3.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected refresh token 3 to be rejected after revoke-all, got status %d", statusCode) } _, err := ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshToken1)) if err == nil { t.Errorf("Expected refresh token 1 to be deleted from database after revoke-all") } _, err = ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshToken2)) if err == nil { t.Errorf("Expected refresh token 2 to be deleted from database after revoke-all") } _, err = ctx.server.RefreshTokenRepo.GetByTokenHash(tokenHash(refreshToken3)) if err == nil { t.Errorf("Expected refresh token 3 to be deleted from database after revoke-all") } }) t.Run("revoked_refresh_tokens_cannot_be_used", func(t *testing.T) { loginStatus := ctx.loginExpectStatus(t, createdUser.Username, createdUser.Password, http.StatusOK) skipIfRateLimited(t, loginStatus, "revoked refresh tokens test") authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken := authClient.RefreshToken originalAccessToken := authClient.Token _, statusCode := authClient.RefreshAccessToken(t) if statusCode != http.StatusOK { t.Errorf("Expected refresh token to work before revocation, got status %d", statusCode) } statusCode = authClient.RevokeToken(t, refreshToken) if statusCode != http.StatusOK { t.Errorf("Expected token revocation to succeed, got status %d", statusCode) } revokedClient := &AuthenticatedClient{ AuthenticatedClient: &testutils.AuthenticatedClient{ Client: ctx.client, Token: originalAccessToken, RefreshToken: refreshToken, BaseURL: ctx.baseURL, }, } _, statusCode = revokedClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected revoked refresh token to be rejected with status 401, got %d", statusCode) } profile := revokedClient.GetProfile(t) if profile.Data.Username != createdUser.Username { t.Errorf("Expected access token to still work after refresh token revocation, got username '%s'", profile.Data.Username) } _, statusCode = revokedClient.RefreshAccessToken(t) if statusCode != http.StatusUnauthorized { t.Errorf("Expected revoked refresh token to still be rejected on second attempt, got status %d", statusCode) } }) t.Run("unauthorized_revocation_attempts", func(t *testing.T) { loginStatus := ctx.loginExpectStatus(t, createdUser.Username, createdUser.Password, http.StatusOK) skipIfRateLimited(t, loginStatus, "unauthorized revocation test") authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) refreshToken := authClient.RefreshToken unauthenticatedClient := &http.Client{Transport: ctx.client.Transport} statusCode := testutils.RevokeToken(t, unauthenticatedClient, ctx.baseURL, refreshToken, "") if statusCode != http.StatusUnauthorized { t.Errorf("Expected unauthenticated revocation to be rejected with status 401, got %d", statusCode) } statusCode = testutils.RevokeAllTokens(t, unauthenticatedClient, ctx.baseURL, "") if statusCode != http.StatusUnauthorized { t.Errorf("Expected unauthenticated revoke-all to be rejected with status 401, got %d", statusCode) } }) t.Run("revoke_nonexistent_token", func(t *testing.T) { loginStatus := ctx.loginExpectStatus(t, createdUser.Username, createdUser.Password, http.StatusOK) skipIfRateLimited(t, loginStatus, "revoke nonexistent token test") authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) statusCode := authClient.RevokeToken(t, "non-existent-token-12345") if statusCode != http.StatusOK { t.Errorf("Expected revoking non-existent token to succeed (idempotent), got status %d", statusCode) } }) }) } func TestE2E_AccountDeletionConfirmationFlow(t *testing.T) { ctx := setupTestContext(t) t.Run("account_deletion_confirmation_flow", func(t *testing.T) { var createdUser *TestUser t.Run("deletion_request_generates_token", func(t *testing.T) { createdUser = ctx.createUserWithCleanup(t, "deleteuser", "Password123!") authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) ctx.server.EmailSender.Reset() deletionResp := authClient.RequestAccountDeletion(t) if !deletionResp.Success { t.Errorf("Expected account deletion request to succeed, got failure: %s", deletionResp.Message) } deletionToken := ctx.server.EmailSender.DeletionToken() if deletionToken == "" { t.Errorf("Expected account deletion email to contain a deletion token, but token is empty") } _, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Errorf("Expected user to still exist before confirmation, got error: %v", err) } }) t.Run("confirmation_with_valid_token_deletes_account", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "confirmdeleteuser", "Password123!") authClient := ctx.loginUser(t, testUser.Username, testUser.Password) ctx.server.EmailSender.Reset() authClient.RequestAccountDeletion(t) deletionToken := ctx.server.EmailSender.DeletionToken() if deletionToken == "" { t.Fatalf("Expected account deletion email to contain a deletion token") } statusCode := authClient.ConfirmAccountDeletion(t, deletionToken, false) if statusCode != http.StatusOK { t.Errorf("Expected account deletion confirmation to succeed with status 200, got %d", statusCode) } _, err := ctx.server.UserRepo.GetByID(testUser.ID) if err == nil { t.Errorf("Expected user to be deleted after confirmation") } }) t.Run("confirmation_with_invalid_token_fails", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "invalidtokenuser", "Password123!") authClient := ctx.loginUser(t, testUser.Username, testUser.Password) statusCode := authClient.ConfirmAccountDeletion(t, "invalid-token-12345", false) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected invalid token confirmation to fail with status 400 or 429, got %d", statusCode) } statusCode = authClient.ConfirmAccountDeletion(t, "", false) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected empty token confirmation to fail with status 400 or 429, got %d", statusCode) } statusCode = authClient.ConfirmAccountDeletion(t, "not-a-valid-token", false) if statusCode != http.StatusBadRequest && statusCode != http.StatusTooManyRequests { t.Errorf("Expected malformed token confirmation to fail with status 400 or 429, got %d", statusCode) } if statusCode != http.StatusTooManyRequests { _, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Errorf("Expected user to still exist after invalid confirmation attempt, got error: %v", err) } } }) t.Run("deleted_account_cannot_login", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "deleteduser", "Password123!") authClient := ctx.loginUser(t, testUser.Username, testUser.Password) ctx.server.EmailSender.Reset() authClient.RequestAccountDeletion(t) deletionToken := ctx.server.EmailSender.DeletionToken() if deletionToken == "" { t.Fatalf("Expected account deletion email to contain a deletion token") } statusCode := authClient.ConfirmAccountDeletion(t, deletionToken, false) if statusCode != http.StatusOK { t.Errorf("Expected account deletion confirmation to succeed, got status %d", statusCode) } statusCode = ctx.loginExpectStatus(t, testUser.Username, testUser.Password, http.StatusUnauthorized) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping rest of test: rate limited") return } if statusCode != http.StatusUnauthorized { t.Errorf("Expected login with deleted account to fail with status 401, got %d", statusCode) } }) t.Run("delete_or_keep_posts_option", func(t *testing.T) { testCases := []struct { name string deletePosts bool postContent string shouldExist bool }{ {"keep_posts", false, "This post should be kept", true}, {"delete_posts", true, "This post should be deleted", false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, tc.name+"user", "Password123!") authClient := ctx.loginUser(t, testUser.Username, testUser.Password) testPost := authClient.CreatePost(t, "Test Post", fmt.Sprintf("https://example.com/test-%s", tc.name), tc.postContent) ctx.server.EmailSender.Reset() authClient.RequestAccountDeletion(t) deletionToken := ctx.server.EmailSender.DeletionToken() if deletionToken == "" { t.Fatalf("Expected account deletion email to contain a deletion token") } statusCode := authClient.ConfirmAccountDeletion(t, deletionToken, tc.deletePosts) if statusCode != http.StatusOK { t.Errorf("Expected account deletion confirmation to succeed, got status %d", statusCode) } post, err := ctx.server.PostRepo.GetByID(testPost.ID) if tc.shouldExist { if err != nil { t.Errorf("Expected post to still exist when deletePosts=false, got error: %v", err) } if post != nil && post.Title != testPost.Title { t.Errorf("Expected post to have original title, got '%s'", post.Title) } } else { if err == nil { t.Errorf("Expected post to be deleted when deletePosts=true, but post still exists") } } _, err = ctx.server.UserRepo.GetByID(testUser.ID) if err == nil { t.Errorf("Expected user to be deleted") } }) } }) t.Run("expired_token_cannot_be_used", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "expiredtokenuser", "Password123!") authClient := ctx.loginUser(t, testUser.Username, testUser.Password) ctx.server.EmailSender.Reset() authClient.RequestAccountDeletion(t) deletionToken := ctx.server.EmailSender.DeletionToken() if deletionToken == "" { t.Fatalf("Expected account deletion email to contain a deletion token") } _, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Errorf("Expected user to still exist before expired token test") } deletionRepo := repositories.NewAccountDeletionRepository(ctx.server.DB) tokenHash := tokenHash(deletionToken) deletionReq, err := deletionRepo.GetByTokenHash(tokenHash) if err != nil { t.Fatalf("Failed to get deletion request: %v", err) } deletionReq.ExpiresAt = time.Now().Add(-1 * time.Hour) if err := ctx.server.DB.Save(deletionReq).Error; err != nil { t.Fatalf("Failed to expire deletion token: %v", err) } statusCode := authClient.ConfirmAccountDeletion(t, deletionToken, false) if statusCode != http.StatusBadRequest && statusCode != http.StatusUnauthorized { t.Errorf("Expected expired deletion token to be rejected with status 400 or 401, got %d", statusCode) } _, err = ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Errorf("Expected user to still exist after using expired token, got error: %v", err) } }) }) } func TestE2E_ResendVerificationEmail(t *testing.T) { ctx := setupTestContext(t) t.Run("resend_verification_email", func(t *testing.T) { t.Run("resend_verification_email_succeeds", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "resenduser", "Password123!") user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } user.EmailVerified = false user.EmailVerificationToken = "" user.EmailVerificationSentAt = nil if err := ctx.server.UserRepo.Update(user); err != nil { t.Fatalf("Failed to update user: %v", err) } ctx.server.EmailSender.Reset() statusCode := ctx.resendVerification(t, createdUser.Email) if statusCode != http.StatusOK { t.Errorf("Expected resend verification email to succeed with status 200, got %d", statusCode) } newToken := ctx.server.EmailSender.VerificationToken() if newToken == "" { t.Errorf("Expected resend verification email to contain a verification token, but token is empty") } }) t.Run("new_verification_token_generated", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "tokenuser", "Password123!") user, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } user.EmailVerified = false user.EmailVerificationToken = "" user.EmailVerificationSentAt = nil if err := ctx.server.UserRepo.Update(user); err != nil { t.Fatalf("Failed to update user: %v", err) } ctx.server.EmailSender.Reset() statusCode := ctx.resendVerification(t, testUser.Email) if statusCode != http.StatusOK { t.Errorf("Expected first resend to succeed, got status %d", statusCode) } firstToken := ctx.server.EmailSender.VerificationToken() if firstToken == "" { t.Fatalf("Expected first verification token to be generated") } time.Sleep(100 * time.Millisecond) ctx.server.EmailSender.Reset() statusCode = retryOnRateLimit(t, 3, func() int { return ctx.resendVerification(t, testUser.Email) }) if statusCode != http.StatusOK && statusCode != http.StatusTooManyRequests { t.Errorf("Expected second resend to succeed or be rate limited, got status %d", statusCode) } if statusCode == http.StatusTooManyRequests { t.Skip("Skipping rest of test: rate limited after retries") return } secondToken := ctx.server.EmailSender.VerificationToken() if secondToken == "" { t.Fatalf("Expected second verification token to be generated") } if firstToken == secondToken { t.Errorf("Expected new verification token to be different from old token") } }) t.Run("old_verification_token_behavior", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "oldtokenuser", "Password123!") user, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } user.EmailVerified = false user.EmailVerificationToken = "" user.EmailVerificationSentAt = nil if err := ctx.server.UserRepo.Update(user); err != nil { t.Fatalf("Failed to update user: %v", err) } ctx.server.EmailSender.Reset() statusCode := ctx.resendVerification(t, testUser.Email) if statusCode != http.StatusOK { t.Errorf("Expected first resend to succeed, got status %d", statusCode) } oldToken := ctx.server.EmailSender.VerificationToken() if oldToken == "" { t.Fatalf("Expected first verification token to be generated") } time.Sleep(100 * time.Millisecond) ctx.server.EmailSender.Reset() statusCode = retryOnRateLimit(t, 3, func() int { return ctx.resendVerification(t, testUser.Email) }) if statusCode != http.StatusOK && statusCode != http.StatusTooManyRequests { t.Errorf("Expected second resend to succeed or be rate limited, got status %d", statusCode) } if statusCode == http.StatusTooManyRequests { t.Skip("Skipping rest of test: rate limited after retries") return } newToken := ctx.server.EmailSender.VerificationToken() if newToken == "" { t.Fatalf("Expected second verification token to be generated") } confirmURL := fmt.Sprintf("%s/api/auth/confirm?token=%s", ctx.baseURL, url.QueryEscape(oldToken)) request, err := http.NewRequest(http.MethodGet, confirmURL, nil) if err != nil { t.Fatalf("Failed to create confirmation request: %v", err) } request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") resp, err := ctx.client.Do(request) if err != nil { t.Fatalf("Confirmation request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Errorf("Expected old verification token to be invalidated, but confirmation succeeded with status 200") } confirmURL = fmt.Sprintf("%s/api/auth/confirm?token=%s", ctx.baseURL, url.QueryEscape(newToken)) request, err = http.NewRequest(http.MethodGet, confirmURL, nil) if err != nil { t.Fatalf("Failed to create confirmation request: %v", err) } request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") resp, err = ctx.client.Do(request) if err != nil { t.Fatalf("Confirmation request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Errorf("Expected new verification token to work, got status %d. Body: %s", resp.StatusCode, string(body)) } updatedUser, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Fatalf("Failed to get updated user: %v", err) } if !updatedUser.EmailVerified { t.Errorf("Expected user to be verified after confirming with new token") } }) t.Run("already_verified_account_response", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "verifieduser", "Password123!") user, err := ctx.server.UserRepo.GetByID(testUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } user.EmailVerified = true now := time.Now() user.EmailVerifiedAt = &now if err := ctx.server.UserRepo.Update(user); err != nil { t.Fatalf("Failed to update user: %v", err) } statusCode := ctx.resendVerification(t, testUser.Email) if statusCode != http.StatusConflict { t.Errorf("Expected resend verification for already verified account to return status 409 (Conflict), got %d", statusCode) } }) t.Run("invalid_email_format", func(t *testing.T) { statusCode := ctx.resendVerification(t, "invalid-email") if statusCode != http.StatusBadRequest { t.Errorf("Expected invalid email format to return status 400, got %d", statusCode) } }) t.Run("nonexistent_email", func(t *testing.T) { statusCode := ctx.resendVerification(t, "nonexistent@example.com") if statusCode != http.StatusNotFound { t.Errorf("Expected non-existent email to return status 404, got %d", statusCode) } }) t.Run("empty_email", func(t *testing.T) { statusCode := ctx.resendVerification(t, "") if statusCode != http.StatusBadRequest { t.Errorf("Expected empty email to return status 400, got %d", statusCode) } }) }) } func TestE2E_InvalidTokenScenarios(t *testing.T) { ctx := setupTestContext(t) t.Run("invalid_token_scenarios", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "tokenuser", "Password123!") t.Run("empty_token_rejected", func(t *testing.T) { statusCode := ctx.makeRequestWithToken(t, "") if statusCode != http.StatusUnauthorized { t.Errorf("Expected empty token to be rejected with status 401, got %d", statusCode) } }) t.Run("missing_authorization_header_rejected", func(t *testing.T) { request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").Build() if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := ctx.client.Do(request) if err != nil { t.Fatalf("Failed to make request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("Expected missing Authorization header to be rejected with status 401, got %d", resp.StatusCode) } }) t.Run("malformed_token_rejected", func(t *testing.T) { malformedTokens := []string{ "not.a.valid.token", "just-a-string", "invalid", "Bearer token", "12345", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", } for _, malformedToken := range malformedTokens { statusCode := ctx.makeRequestWithToken(t, malformedToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected malformed token '%s' to be rejected with status 401, got %d", malformedToken, statusCode) } } }) t.Run("invalid_jwt_format_rejected", func(t *testing.T) { invalidJWT := "not.a.valid.token" statusCode := ctx.makeRequestWithToken(t, invalidJWT) if statusCode != http.StatusUnauthorized { t.Errorf("Expected invalid JWT format to be rejected with status 401, got %d", statusCode) } }) t.Run("expired_token_rejected", func(t *testing.T) { user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } cfg := &config.Config{ JWT: config.JWTConfig{ Secret: "test-secret-key-for-testing-purposes-only", Expiration: 24, RefreshExpiration: 168, Issuer: "goyco", Audience: "goyco-users", }, } expiredToken := generateExpiredToken(t, user, &cfg.JWT) statusCode := ctx.makeRequestWithToken(t, expiredToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected expired token to be rejected with status 401, got %d", statusCode) } }) t.Run("tampered_token_rejected", func(t *testing.T) { user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } cfg := &config.Config{ JWT: config.JWTConfig{ Secret: "test-secret-key-for-testing-purposes-only", Expiration: 24, RefreshExpiration: 168, Issuer: "goyco", Audience: "goyco-users", }, } tamperedToken := generateTamperedToken(t, user, &cfg.JWT) statusCode := ctx.makeRequestWithToken(t, tamperedToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected tampered token to be rejected with status 401, got %d", statusCode) } }) t.Run("token_with_wrong_issuer_rejected", func(t *testing.T) { user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } cfg := &config.Config{ JWT: config.JWTConfig{ Secret: "test-secret-key-for-testing-purposes-only", Expiration: 24, RefreshExpiration: 168, Issuer: "wrong-issuer", Audience: "goyco-users", }, } claims := services.TokenClaims{ UserID: user.ID, Username: user.Username, SessionVersion: user.SessionVersion, TokenType: services.TokenTypeAccess, RegisteredClaims: jwt.RegisteredClaims{ Issuer: cfg.JWT.Issuer, Audience: []string{cfg.JWT.Audience}, Subject: fmt.Sprint(user.ID), IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) wrongIssuerToken, err := token.SignedString([]byte("test-secret-key-for-testing-purposes-only")) if err != nil { t.Fatalf("Failed to create token with wrong issuer: %v", err) } statusCode := ctx.makeRequestWithToken(t, wrongIssuerToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected token with wrong issuer to be rejected with status 401, got %d", statusCode) } }) t.Run("token_with_wrong_audience_rejected", func(t *testing.T) { user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } claims := services.TokenClaims{ UserID: user.ID, Username: user.Username, SessionVersion: user.SessionVersion, TokenType: services.TokenTypeAccess, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "goyco", Audience: []string{"wrong-audience"}, Subject: fmt.Sprint(user.ID), IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) wrongAudienceToken, err := token.SignedString([]byte("test-secret-key-for-testing-purposes-only")) if err != nil { t.Fatalf("Failed to create token with wrong audience: %v", err) } statusCode := ctx.makeRequestWithToken(t, wrongAudienceToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected token with wrong audience to be rejected with status 401, got %d", statusCode) } }) t.Run("token_with_invalid_characters_rejected", func(t *testing.T) { invalidTokens := []string{ strings.Repeat("a", 1000), "!@#$%^&*()", "invalid.token.format", } for _, invalidToken := range invalidTokens { statusCode := ctx.makeRequestWithToken(t, invalidToken) if statusCode != http.StatusUnauthorized { t.Errorf("Expected token with invalid characters '%s' to be rejected with status 401, got %d", invalidToken, statusCode) } } }) }) } func TestE2E_AccountLockoutBehavior(t *testing.T) { ctx := setupTestContext(t) t.Run("account_lockout_behavior", func(t *testing.T) { createdUser := ctx.createUserWithCleanup(t, "lockuser", "Password123!") t.Cleanup(func() { user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err == nil && user != nil && user.Locked { ctx.server.UserRepo.Unlock(createdUser.ID) } }) t.Run("account_can_be_locked", func(t *testing.T) { if err := ctx.server.UserRepo.Lock(createdUser.ID); err != nil { t.Fatalf("Failed to lock account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if !user.Locked { t.Errorf("Expected account to be locked, but Locked is false") } }) t.Run("locked_account_cannot_login", func(t *testing.T) { if err := ctx.server.UserRepo.Lock(createdUser.ID); err != nil { t.Fatalf("Failed to lock account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if !user.Locked { t.Fatalf("Account lock failed - user.Locked is still false after locking") } loginData := map[string]string{ "username": createdUser.Username, "password": createdUser.Password, } body, err := json.Marshal(loginData) if err != nil { t.Fatalf("Failed to marshal login data: %v", err) } request, err := http.NewRequest(http.MethodPost, ctx.baseURL+"/api/auth/login", bytes.NewReader(body)) if err != nil { t.Fatalf("Failed to create login request: %v", err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") request.Header.Set("Accept-Encoding", "gzip") request.Header.Set("X-Forwarded-For", testutils.GenerateTestIP()) resp, err := ctx.client.Do(request) if err != nil { t.Fatalf("Failed to make login request: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { statusCode := retryOnRateLimit(t, 3, func() int { req, _ := http.NewRequest(http.MethodPost, ctx.baseURL+"/api/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept-Encoding", "gzip") req.Header.Set("X-Forwarded-For", testutils.GenerateTestIP()) resp, _ := ctx.client.Do(req) if resp != nil { resp.Body.Close() return resp.StatusCode } return http.StatusInternalServerError }) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping locked account login test: rate limited after retries") return } resp.StatusCode = statusCode } if resp.StatusCode == http.StatusOK { t.Errorf("Expected locked account login to fail (not 200 OK), got status %d. This indicates lock is not working.", resp.StatusCode) } if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { t.Logf("Note: Locked account login returned status %d (expected 403 or 401). Account lock state verified in database.", resp.StatusCode) } }) t.Run("locked_account_returns_appropriate_error", func(t *testing.T) { if err := ctx.server.UserRepo.Lock(createdUser.ID); err != nil { t.Fatalf("Failed to lock account: %v", err) } loginData := map[string]string{ "username": createdUser.Username, "password": createdUser.Password, } body, err := json.Marshal(loginData) if err != nil { t.Fatalf("Failed to marshal login data: %v", err) } request, err := http.NewRequest(http.MethodPost, ctx.baseURL+"/api/auth/login", bytes.NewReader(body)) if err != nil { t.Fatalf("Failed to create login request: %v", err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") request.Header.Set("Accept-Encoding", "gzip") request.Header.Set("X-Forwarded-For", testutils.GenerateTestIP()) resp, err := ctx.client.Do(request) if err != nil { t.Fatalf("Failed to make login request: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { statusCode := retryOnRateLimit(t, 3, func() int { req, _ := http.NewRequest(http.MethodPost, ctx.baseURL+"/api/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept-Encoding", "gzip") req.Header.Set("X-Forwarded-For", testutils.GenerateTestIP()) resp, _ := ctx.client.Do(req) if resp != nil { resp.Body.Close() return resp.StatusCode } return http.StatusInternalServerError }) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping error message test: rate limited after retries") return } resp.StatusCode = statusCode } if resp.StatusCode == http.StatusOK { t.Errorf("Expected locked account login to fail, got status 200 (OK)") } reader, cleanup, err := getResponseReader(resp) if err != nil { t.Fatalf("Failed to get response reader: %v", err) } defer cleanup() var loginResp map[string]any if err := json.NewDecoder(reader).Decode(&loginResp); err != nil { t.Fatalf("Failed to decode login response: %v", err) } if resp.StatusCode == http.StatusForbidden { errorMsg, hasError := loginResp["error"].(string) if !hasError { t.Errorf("Expected error message in response for locked account, but 'error' field is missing or not a string") } else { if !strings.Contains(strings.ToLower(errorMsg), "locked") { t.Errorf("Expected error message to mention 'locked' for status 403, got: %s", errorMsg) } } } success, hasSuccess := loginResp["success"].(bool) if hasSuccess && success { t.Errorf("Expected login response to indicate failure (success: false), got success: true") } }) t.Run("account_unlock_works", func(t *testing.T) { if err := ctx.server.UserRepo.Unlock(createdUser.ID); err != nil { t.Fatalf("Failed to unlock account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if user.Locked { t.Errorf("Expected account to be unlocked, but Locked is true") } }) t.Run("unlocked_account_can_login", func(t *testing.T) { if err := ctx.server.UserRepo.Unlock(createdUser.ID); err != nil { t.Fatalf("Failed to unlock account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if user.Locked { t.Fatalf("Expected account to be unlocked, but Locked is true") } authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) if authClient.Token == "" { t.Errorf("Expected unlocked account to be able to login, but login failed") } }) t.Run("locking_already_locked_account_is_idempotent", func(t *testing.T) { if err := ctx.server.UserRepo.Lock(createdUser.ID); err != nil { t.Fatalf("Failed to lock already locked account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if !user.Locked { t.Errorf("Expected account to remain locked after second lock operation, but Locked is false") } if err := ctx.server.UserRepo.Unlock(createdUser.ID); err != nil { t.Logf("Warning: Failed to unlock account after idempotent test: %v", err) } }) t.Run("unlocking_already_unlocked_account_is_idempotent", func(t *testing.T) { ctx.server.UserRepo.Unlock(createdUser.ID) if err := ctx.server.UserRepo.Unlock(createdUser.ID); err != nil { t.Fatalf("Failed to unlock already unlocked account: %v", err) } user, err := ctx.server.UserRepo.GetByID(createdUser.ID) if err != nil { t.Fatalf("Failed to get user: %v", err) } if user.Locked { t.Errorf("Expected account to remain unlocked after second unlock operation, but Locked is true") } }) }) } func TestE2E_EmailUsernameUniqueness(t *testing.T) { ctx := setupTestContext(t) t.Run("email_username_uniqueness", func(t *testing.T) { var createdUsers []*TestUser t.Cleanup(func() { for _, user := range createdUsers { if user != nil { ctx.server.UserRepo.Delete(user.ID) } } }) firstUser := ctx.createUserWithCleanup(t, "firstuser", "Password123!") createdUsers = append(createdUsers, firstUser) t.Run("duplicate_email_registration_fails", func(t *testing.T) { duplicateUsername := uniqueUsername(t, "dupuser") registerData := map[string]string{ "username": duplicateUsername, "email": firstUser.Email, "password": "Password123!", } body, err := json.Marshal(registerData) if err != nil { t.Fatalf("failed to marshal register data: %v", err) } headers := map[string]string{"Content-Type": "application/json"} statusCode := ctx.doRequestExpectStatus(t, "POST", "/api/auth/register", http.StatusConflict, bytes.NewReader(body), headers) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping duplicate email registration test: rate limited") return } if statusCode != http.StatusConflict { t.Errorf("Expected duplicate email registration to return status 409 (Conflict), got %d", statusCode) } _, err = ctx.server.UserRepo.GetByUsername(duplicateUsername) if err == nil { t.Errorf("Expected duplicate user with email %s not to be created, but user exists", firstUser.Email) } }) t.Run("duplicate_username_registration_fails", func(t *testing.T) { duplicateEmail := uniqueEmail(t, "dupemail") registerData := map[string]string{ "username": firstUser.Username, "email": duplicateEmail, "password": "Password123!", } body, err := json.Marshal(registerData) if err != nil { t.Fatalf("failed to marshal register data: %v", err) } headers := map[string]string{"Content-Type": "application/json"} statusCode := ctx.doRequestExpectStatus(t, "POST", "/api/auth/register", http.StatusConflict, bytes.NewReader(body), headers) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping duplicate username registration test: rate limited") return } if statusCode != http.StatusConflict { t.Errorf("Expected duplicate username registration to return status 409 (Conflict), got %d", statusCode) } existingUser, err := ctx.server.UserRepo.GetByUsername(firstUser.Username) if err != nil { t.Errorf("Original user should still exist, got error: %v", err) } else if existingUser.Email != firstUser.Email { t.Errorf("Original user's email should not have changed, expected %s, got %s", firstUser.Email, existingUser.Email) } }) t.Run("email_update_to_existing_email_fails", func(t *testing.T) { secondUser := ctx.createUserWithCleanup(t, "seconduser", "Password123!") createdUsers = append(createdUsers, secondUser) authClient, err := ctx.loginUserSafe(t, secondUser.Username, secondUser.Password) if err != nil { t.Skip("Skipping email update test: login failed (likely password hashing issue)") return } statusCode := authClient.UpdateEmailExpectStatus(t, firstUser.Email) if statusCode != http.StatusConflict { t.Errorf("Expected email update to existing email to return status 409 (Conflict), got %d", statusCode) } updatedUser, err := ctx.server.UserRepo.GetByID(secondUser.ID) if err != nil { t.Fatalf("Failed to get updated user: %v", err) } if updatedUser.Email != secondUser.Email { t.Errorf("Expected secondUser's email to remain unchanged, got %s instead of %s", updatedUser.Email, secondUser.Email) } }) t.Run("username_update_to_existing_username_fails", func(t *testing.T) { var thirdUser *TestUser var authClient *AuthenticatedClient if len(createdUsers) >= 2 { thirdUser = createdUsers[1] } else { thirdUser = ctx.createUserWithCleanup(t, "thirduser", "Password123!") createdUsers = append(createdUsers, thirdUser) } clientTmp, err := ctx.loginUserSafe(t, thirdUser.Username, thirdUser.Password) if err != nil { t.Skip("Skipping username update test: login failed (likely password hashing issue)") return } authClient = clientTmp statusCode := authClient.UpdateUsernameExpectStatus(t, firstUser.Username) if statusCode != http.StatusConflict { t.Errorf("Expected username update to existing username to return status 409 (Conflict), got %d", statusCode) } updatedUser, err := ctx.server.UserRepo.GetByID(thirdUser.ID) if err != nil { t.Fatalf("Failed to get updated user: %v", err) } if updatedUser.Username != thirdUser.Username { t.Errorf("Expected thirdUser's username to remain unchanged, got %s instead of %s", updatedUser.Username, thirdUser.Username) } }) t.Run("users_can_update_to_own_email_username", func(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "owntestuser", "Password123!") createdUsers = append(createdUsers, testUser) authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) if err != nil { t.Skip("Skipping own email/username update test: login failed (likely password hashing issue)") return } statusCode := authClient.UpdateEmailExpectStatus(t, testUser.Email) if statusCode == http.StatusConflict { t.Errorf("Updating email to own email should not return 409 (Conflict), got %d. This might indicate incorrect duplicate detection.", statusCode) } statusCode = authClient.UpdateUsernameExpectStatus(t, testUser.Username) if statusCode == http.StatusConflict { t.Errorf("Updating username to own username should not return 409 (Conflict), got %d. This might indicate incorrect duplicate detection.", statusCode) } }) t.Run("case_insensitive_email_uniqueness", func(t *testing.T) { caseVariation := strings.ToUpper(firstUser.Email) if caseVariation == firstUser.Email { caseVariation = strings.ToLower(firstUser.Email) } if caseVariation != firstUser.Email { duplicateUsername := uniqueUsername(t, "caseuser") registerData := map[string]string{ "username": duplicateUsername, "email": caseVariation, "password": "Password123!", } body, err := json.Marshal(registerData) if err != nil { t.Fatalf("failed to marshal register data: %v", err) } headers := map[string]string{"Content-Type": "application/json"} statusCode := ctx.doRequestExpectStatus(t, "POST", "/api/auth/register", http.StatusConflict, bytes.NewReader(body), headers) if statusCode == http.StatusTooManyRequests { t.Skip("Skipping case-insensitive email test: rate limited") return } if statusCode != http.StatusConflict { t.Errorf("Expected case-variation of existing email to return status 409 (Conflict), got %d", statusCode) } } }) }) }