diff --git a/internal/e2e/auth_core_test.go b/internal/e2e/auth_core_test.go new file mode 100644 index 0000000..04f1be1 --- /dev/null +++ b/internal/e2e/auth_core_test.go @@ -0,0 +1,776 @@ +package e2e + +import ( + "fmt" + "net/http" + "testing" + "time" + + "goyco/internal/repositories" + "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 := ctx.loginExpectStatusWithIP(t, createdUser.Username, originalPassword, http.StatusUnauthorized, testutils.GenerateTestIP()) + failIfRateLimited(t, statusCode, "old password login check") + + 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 := testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Email, testutils.GenerateTestIP()) + failIfRateLimited(t, resetStatus, "password reset token generation") + 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 := testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Username, testutils.GenerateTestIP()) + failIfRateLimited(t, statusCode, "password reset by username") + if statusCode != http.StatusOK { + t.Errorf("Expected password reset request by username to succeed, got status %d", statusCode) + } + + 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 + + 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.Errorf("Expected refresh token to rotate") + } + + 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 := ctx.loginExpectStatusWithIP(t, createdUser.Username, createdUser.Password, http.StatusOK, testutils.GenerateTestIP()) + failIfRateLimited(t, loginStatus, "initial login before refresh loop") + + authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password) + + for i := range 3 { + newAccessToken, statusCode := authClient.RefreshAccessToken(t, testutils.GenerateTestIP()) + failIfRateLimited(t, statusCode, fmt.Sprintf("refresh operation %d", i+1)) + if statusCode != http.StatusOK { + t.Errorf("Expected refresh token operation %d to succeed, got status %d", i+1, statusCode) + } + + 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 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) + failIfRateLimited(t, statusCode, "deleted account login") + + 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) + } + }) + }) +} diff --git a/internal/e2e/auth_edge_cases_test.go b/internal/e2e/auth_edge_cases_test.go new file mode 100644 index 0000000..5cc27b4 --- /dev/null +++ b/internal/e2e/auth_edge_cases_test.go @@ -0,0 +1,841 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "goyco/internal/config" + "goyco/internal/services" + "goyco/internal/testutils" + + "github.com/golang-jwt/jwt/v5" +) + +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() + + firstIP := testutils.GenerateTestIP() + statusCode := ctx.resendVerificationWithIP(t, testUser.Email, firstIP) + if statusCode != http.StatusOK { + t.Errorf("Expected first resend to succeed, got status %d", statusCode) + } + failIfRateLimited(t, statusCode, "first resend verification") + + firstToken := ctx.server.EmailSender.VerificationToken() + if firstToken == "" { + t.Fatalf("Expected first verification token to be generated") + } + + userAfterFirstResend, err := ctx.server.UserRepo.GetByID(testUser.ID) + if err != nil { + t.Fatalf("Failed to reload user after first resend: %v", err) + } + firstTokenHash := userAfterFirstResend.EmailVerificationToken + userAfterFirstResend.EmailVerificationSentAt = nil + if err := ctx.server.UserRepo.Update(userAfterFirstResend); err != nil { + t.Fatalf("Failed to reset verification resend cooldown: %v", err) + } + + ctx.server.EmailSender.Reset() + + secondIP := testutils.GenerateTestIP() + statusCode = ctx.resendVerificationWithIP(t, testUser.Email, secondIP) + failIfRateLimited(t, statusCode, "second resend verification") + if statusCode != http.StatusOK { + t.Errorf("Expected second resend to succeed, got status %d", statusCode) + } + + secondToken := ctx.server.EmailSender.VerificationToken() + if secondToken == "" { + t.Fatalf("Expected second verification token to be generated") + } + + ctx.assertEventually(t, func() bool { + updatedUser, err := ctx.server.UserRepo.GetByID(testUser.ID) + if err != nil { + return false + } + return updatedUser.EmailVerificationToken != "" && updatedUser.EmailVerificationToken != firstTokenHash + }, 1*time.Second) + + 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() + + firstIP := testutils.GenerateTestIP() + statusCode := ctx.resendVerificationWithIP(t, testUser.Email, firstIP) + if statusCode != http.StatusOK { + t.Errorf("Expected first resend to succeed, got status %d", statusCode) + } + failIfRateLimited(t, statusCode, "first resend verification for old token") + + oldToken := ctx.server.EmailSender.VerificationToken() + if oldToken == "" { + t.Fatalf("Expected first verification token to be generated") + } + + userAfterFirstResend, err := ctx.server.UserRepo.GetByID(testUser.ID) + if err != nil { + t.Fatalf("Failed to reload user after first resend: %v", err) + } + userAfterFirstResend.EmailVerificationSentAt = nil + if err := ctx.server.UserRepo.Update(userAfterFirstResend); err != nil { + t.Fatalf("Failed to reset verification resend cooldown: %v", err) + } + + ctx.server.EmailSender.Reset() + + secondIP := testutils.GenerateTestIP() + statusCode = ctx.resendVerificationWithIP(t, testUser.Email, secondIP) + failIfRateLimited(t, statusCode, "second resend verification for old token") + if statusCode != http.StatusOK { + t.Errorf("Expected second resend to succeed, got status %d", statusCode) + } + + 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() + + failIfRateLimited(t, resp.StatusCode, "locked account login behavior") + + 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() + + failIfRateLimited(t, resp.StatusCode, "locked account error response") + + 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) + failIfRateLimited(t, statusCode, "duplicate email registration") + + 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) + failIfRateLimited(t, statusCode, "duplicate username registration") + + 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) + failIfRateLimited(t, statusCode, "case-insensitive duplicate email registration") + + if statusCode != http.StatusConflict { + t.Errorf("Expected case-variation of existing email to return status 409 (Conflict), got %d", statusCode) + } + } + }) + }) +} diff --git a/internal/e2e/auth_test.go b/internal/e2e/auth_test.go deleted file mode 100644 index 9affea6..0000000 --- a/internal/e2e/auth_test.go +++ /dev/null @@ -1,1684 +0,0 @@ -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.Errorf("Expected refresh token to rotate") - } - - 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) - } - } - }) - }) -} diff --git a/internal/e2e/common.go b/internal/e2e/common.go index e4af95d..ec0076d 100644 --- a/internal/e2e/common.go +++ b/internal/e2e/common.go @@ -269,6 +269,13 @@ func skipIfRateLimited(t *testing.T, statusCode int, reason string) { } } +func failIfRateLimited(t *testing.T, statusCode int, operation string) { + t.Helper() + if statusCode == http.StatusTooManyRequests { + t.Fatalf("%s was rate limited unexpectedly (429); this test expects deterministic isolation", operation) + } +} + func setupTestContext(t *testing.T) *testContext { t.Helper() server := setupIntegrationTestServer(t) @@ -782,6 +789,11 @@ func (ctx *testContext) resendVerification(t *testing.T, email string) int { return testutils.ResendVerificationEmail(t, ctx.client, ctx.baseURL, email) } +func (ctx *testContext) resendVerificationWithIP(t *testing.T, email, ipAddress string) int { + t.Helper() + return testutils.ResendVerificationEmailWithIP(t, ctx.client, ctx.baseURL, email, ipAddress) +} + func (ctx *testContext) createTestFixtures(t *testing.T) *TestFixtures { t.Helper() fixtures := &TestFixtures{} diff --git a/internal/e2e/security_test.go b/internal/e2e/security_test.go index 1492a61..39d839d 100644 --- a/internal/e2e/security_test.go +++ b/internal/e2e/security_test.go @@ -114,84 +114,6 @@ func TestE2E_SearchSanitization(t *testing.T) { }) } -func TestE2E_SecurityHeaders(t *testing.T) { - ctx := setupTestContext(t) - - expectedHeaders := map[string]string{ - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "X-XSS-Protection": "1; mode=block", - "Referrer-Policy": "strict-origin-when-cross-origin", - } - - type endpointTest struct { - name string - method string - path string - auth bool - body []byte - } - - endpoints := []endpointTest{ - {name: "health_endpoint", method: "GET", path: "/health", auth: false}, - {name: "metrics_endpoint", method: "GET", path: "/metrics", auth: false}, - {name: "api_registration", method: "POST", path: "/api/auth/register", auth: false, body: []byte(`{"username":"testuser","email":"test@example.com","password":"StrongPass123!"}`)}, - {name: "api_posts", method: "GET", path: "/api/posts", auth: true}, - {name: "api_auth_me", method: "GET", path: "/api/auth/me", auth: true}, - } - - t.Run("security_headers_on_all_endpoints", func(t *testing.T) { - testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!") - var authToken string - - authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) - if err == nil { - authToken = authClient.Token - } - - for _, endpoint := range endpoints { - t.Run(endpoint.name, func(t *testing.T) { - var req *http.Request - var err error - - if endpoint.body != nil { - req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, bytes.NewReader(endpoint.body)) - } else { - req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil) - } - - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - - if endpoint.auth && authToken != "" { - req.Header.Set("Authorization", "Bearer "+authToken) - } - - testutils.WithStandardHeaders(req) - - resp, err := ctx.client.Do(req) - if err != nil { - t.Fatalf("Failed to make request: %v", err) - } - defer resp.Body.Close() - - for headerName, expectedValue := range expectedHeaders { - actualValue := resp.Header.Get(headerName) - if actualValue != expectedValue { - t.Errorf("Endpoint %s: Expected %s header to be '%s', got '%s'", endpoint.path, headerName, expectedValue, actualValue) - } - } - - csp := resp.Header.Get("Content-Security-Policy") - if csp == "" { - t.Errorf("Endpoint %s: Content-Security-Policy header should be present", endpoint.path) - } - }) - } - }) -} - func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) { ctx := setupTestContext(t) @@ -201,7 +123,7 @@ func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!") authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) if err != nil { - t.Skipf("Skipping sql injection in post fields test: %v", err) + t.Fatalf("Failed to login for SQL injection post fields test: %v", err) } for i, payload := range sqlPayloads { @@ -313,7 +235,7 @@ func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "sqltest2", "StrongPass123!") authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) if err != nil { - t.Skipf("Skipping sql injection in url fields test: %v", err) + t.Fatalf("Failed to login for SQL injection URL fields test: %v", err) } for i, payload := range sqlPayloads { @@ -349,7 +271,7 @@ func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) { testUser := ctx.createUserWithCleanup(t, "sqltest3", "StrongPass123!") authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) if err != nil { - t.Skipf("Skipping sql injection in query parameters test: %v", err) + t.Fatalf("Failed to login for SQL injection query parameter test: %v", err) } _ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content") @@ -641,16 +563,18 @@ func TestE2E_SecurityHeadersEnhanced(t *testing.T) { method string path string auth bool + body []byte }{ - {"health", "GET", "/health", false}, - {"metrics", "GET", "/metrics", false}, - {"api_posts", "GET", "/api/posts", true}, - {"api_auth_me", "GET", "/api/auth/me", true}, + {"health", "GET", "/health", false, nil}, + {"metrics", "GET", "/metrics", false, nil}, + {"api_registration", "POST", "/api/auth/register", false, []byte(`{"username":"headertestnew","email":"headertestnew@example.com","password":"StrongPass123!"}`)}, + {"api_posts", "GET", "/api/posts", true, nil}, + {"api_auth_me", "GET", "/api/auth/me", true, nil}, } for _, endpoint := range endpoints { t.Run(endpoint.name, func(t *testing.T) { - req, err := http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil) + req, err := http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, bytes.NewReader(endpoint.body)) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -710,83 +634,6 @@ func TestE2E_SecurityHeadersEnhanced(t *testing.T) { }) } -func TestE2E_ParameterizedQueries(t *testing.T) { - ctx := setupTestContext(t) - - t.Run("sql_injection_prevention", func(t *testing.T) { - testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!") - authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) - if err != nil { - t.Fatalf("Failed to login: %v", err) - } - - for i, payload := range testutils.SQLInjectionPayloads { - t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) { - postData := map[string]string{ - "title": payload, - "url": fmt.Sprintf("https://example.com/test%d", i), - "content": "Test content", - } - - req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts"). - WithAuth(authClient.Token). - WithJSONBody(postData). - Build() - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - - resp, err := ctx.client.Do(req) - if err != nil { - t.Fatalf("Failed to make request: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusInternalServerError { - t.Errorf("SQL injection in title caused server error (500). Payload: %s", payload) - } - - if resp.StatusCode != http.StatusCreated { - t.Errorf("Expected post creation to succeed (parameterized queries prevent SQL injection), got status: %d", resp.StatusCode) - } - }) - } - }) - - t.Run("search_sanitization", func(t *testing.T) { - testUser := ctx.createUserWithCleanup(t, "searchtest", "StrongPass123!") - authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password) - if err != nil { - t.Fatalf("Failed to login: %v", err) - } - - _ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content") - - for i, payload := range testutils.SQLInjectionPayloads { - t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) { - searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload) - - req, err := testutils.NewRequestBuilder("GET", searchURL). - WithAuth(authClient.Token). - Build() - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - - resp, err := ctx.client.Do(req) - if err != nil { - t.Fatalf("Failed to make request: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusInternalServerError { - t.Errorf("SQL injection in search query caused server error (500). Payload: %s", payload) - } - }) - } - }) -} - func TestE2E_TokenHashing(t *testing.T) { ctx := setupTestContext(t) diff --git a/internal/testutils/e2e.go b/internal/testutils/e2e.go index aa824c0..3c43957 100644 --- a/internal/testutils/e2e.go +++ b/internal/testutils/e2e.go @@ -1252,6 +1252,11 @@ func ConfirmAccountDeletion(t *testing.T, client *http.Client, baseURL, token st func ResendVerificationEmail(t *testing.T, client *http.Client, baseURL, email string) int { t.Helper() + return ResendVerificationEmailWithIP(t, client, baseURL, email, "") +} + +func ResendVerificationEmailWithIP(t *testing.T, client *http.Client, baseURL, email, ipAddress string) int { + t.Helper() requestData := map[string]string{ "email": email, @@ -1268,6 +1273,9 @@ func ResendVerificationEmail(t *testing.T, client *http.Client, baseURL, email s } request.Header.Set("Content-Type", "application/json") WithStandardHeaders(request) + if ipAddress != "" { + request.Header.Set("X-Forwarded-For", ipAddress) + } resp, err := client.Do(request) if err != nil {