1684 lines
58 KiB
Go
1684 lines
58 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"goyco/internal/config"
|
|
"goyco/internal/repositories"
|
|
"goyco/internal/services"
|
|
"goyco/internal/testutils"
|
|
)
|
|
|
|
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 := 0; attempt < 3; attempt++ {
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|