508 lines
14 KiB
Go
508 lines
14 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"goyco/internal/database"
|
|
"goyco/internal/testutils"
|
|
)
|
|
|
|
func TestE2E_PartialFailureHandling(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("partial_failure_handling", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "partial", "Password123!")
|
|
|
|
post := authClient.CreatePost(t, "Partial Failure Test", "https://example.com/partial", "Content")
|
|
if post.ID == 0 {
|
|
t.Fatalf("Expected post creation to succeed")
|
|
}
|
|
|
|
postsResp := authClient.GetPosts(t)
|
|
foundPost := findPostInList(postsResp, post.ID)
|
|
if foundPost == nil {
|
|
t.Fatalf("Expected post to exist after creation")
|
|
}
|
|
|
|
invalidPostID := uint(999999)
|
|
voteResp, statusCode := authClient.VoteOnPostRaw(t, invalidPostID, "up")
|
|
if statusCode == http.StatusOK || voteResp.Success {
|
|
t.Errorf("Expected vote on non-existent post to fail")
|
|
}
|
|
|
|
postsRespAfter := authClient.GetPosts(t)
|
|
foundPostAfter := findPostInList(postsRespAfter, post.ID)
|
|
if foundPostAfter == nil {
|
|
t.Errorf("Expected post to still exist after vote failure")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ConcurrentModification(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("concurrent_modification", func(t *testing.T) {
|
|
user1 := ctx.createUserWithCleanup(t, "concmode1", "Password123!")
|
|
user2 := ctx.createUserWithCleanup(t, "concmode2", "Password123!")
|
|
|
|
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
|
post := client1.CreatePost(t, "Concurrent Edit Test", "https://example.com/concmode", "Original content")
|
|
|
|
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
|
|
|
statusCode := client2.UpdatePostExpectStatus(t, post.ID, "Hacked Title", "https://example.com/concmode", "Hacked content")
|
|
if statusCode != http.StatusForbidden {
|
|
t.Errorf("Expected 403 Forbidden when user2 tries to edit user1's post, got %d", statusCode)
|
|
}
|
|
|
|
postsResp := client1.GetPosts(t)
|
|
updatedPost := findPostInList(postsResp, post.ID)
|
|
if updatedPost == nil {
|
|
t.Fatalf("Expected post to exist")
|
|
}
|
|
if updatedPost.Title != "Concurrent Edit Test" {
|
|
t.Errorf("Expected post title to remain unchanged after unauthorized edit attempt, got '%s'", updatedPost.Title)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ResourceNotFound(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("resource_not_found", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "notfound", "Password123!")
|
|
|
|
post := authClient.CreatePost(t, "To Delete", "https://example.com/todelete", "Content")
|
|
authClient.DeletePost(t, post.ID)
|
|
|
|
statusCode := authClient.UpdatePostExpectStatus(t, post.ID, "Updated", "https://example.com/todelete", "Updated")
|
|
if statusCode != http.StatusNotFound {
|
|
t.Errorf("Expected 404 Not Found when accessing deleted post, got %d", statusCode)
|
|
}
|
|
|
|
voteResp, statusCode := authClient.VoteOnPostRaw(t, post.ID, "up")
|
|
if statusCode == http.StatusOK || voteResp.Success {
|
|
t.Errorf("Expected vote on deleted post to fail")
|
|
}
|
|
|
|
postsResp := authClient.GetPosts(t)
|
|
deletedPost := findPostInList(postsResp, post.ID)
|
|
if deletedPost != nil {
|
|
t.Errorf("Expected deleted post to not appear in posts list")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_InvalidStateTransitions(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("invalid_state_transitions", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "invalidstate", "Password123!")
|
|
|
|
post := authClient.CreatePost(t, "State Test", "https://example.com/state", "Content")
|
|
|
|
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
|
if !voteResp.Success {
|
|
t.Errorf("Expected vote to succeed")
|
|
}
|
|
|
|
authClient.DeletePost(t, post.ID)
|
|
|
|
voteRespAfter, statusCode := authClient.VoteOnPostRaw(t, post.ID, "down")
|
|
if statusCode == http.StatusOK || voteRespAfter.Success {
|
|
t.Errorf("Expected vote on deleted post to fail")
|
|
}
|
|
|
|
statusCode = authClient.UpdatePostExpectStatus(t, post.ID, "Updated", "https://example.com/state", "Updated")
|
|
if statusCode != http.StatusNotFound {
|
|
t.Errorf("Expected 404 when updating deleted post, got %d", statusCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_RequestTimeoutHandling(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("request_timeout_handling", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "timeout", "Password123!")
|
|
|
|
client := &http.Client{
|
|
Timeout: 1 * time.Nanosecond,
|
|
}
|
|
|
|
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
_, err = client.Do(request)
|
|
if err == nil {
|
|
t.Log("Request completed despite timeout (acceptable if server is very fast)")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_SlowResponseHandling(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("slow_response_handling", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "slow", "Password123!")
|
|
|
|
start := time.Now()
|
|
postsResp := authClient.GetPosts(t)
|
|
duration := time.Since(start)
|
|
|
|
if postsResp == nil {
|
|
t.Errorf("Expected posts response even with slow response")
|
|
}
|
|
|
|
if duration > 30*time.Second {
|
|
t.Errorf("Request took too long: %v", duration)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_MalformedInput(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("malformed_input", func(t *testing.T) {
|
|
_, authClient := ctx.createUserAndLogin(t, "malformed", "Password123!")
|
|
|
|
t.Run("very_long_title", func(t *testing.T) {
|
|
longTitle := make([]byte, 201)
|
|
for i := range longTitle {
|
|
longTitle[i] = 'A'
|
|
}
|
|
|
|
postData := map[string]string{
|
|
"title": string(longTitle),
|
|
"url": "https://example.com/long",
|
|
}
|
|
body, _ := json.Marshal(postData)
|
|
|
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
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.StatusCreated {
|
|
t.Errorf("Expected long title to be rejected")
|
|
}
|
|
})
|
|
|
|
t.Run("very_long_content", func(t *testing.T) {
|
|
longContent := make([]byte, 10001)
|
|
for i := range longContent {
|
|
longContent[i] = 'B'
|
|
}
|
|
|
|
postData := map[string]string{
|
|
"title": "Test",
|
|
"url": "https://example.com/longcontent",
|
|
"content": string(longContent),
|
|
}
|
|
body, _ := json.Marshal(postData)
|
|
|
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
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.StatusCreated {
|
|
t.Errorf("Expected long content to be rejected")
|
|
}
|
|
})
|
|
|
|
t.Run("special_characters", func(t *testing.T) {
|
|
specialChars := []string{
|
|
"<script>alert('XSS')</script>",
|
|
"'; DROP TABLE posts; --",
|
|
"æµ‹è¯•ä¸æ–‡",
|
|
"🚀 Emoji Test",
|
|
"Test\nNewline",
|
|
"Test\tTab",
|
|
}
|
|
|
|
for _, special := range specialChars {
|
|
postData := map[string]string{
|
|
"title": special,
|
|
"url": "https://example.com/special",
|
|
"content": special,
|
|
}
|
|
body, _ := json.Marshal(postData)
|
|
|
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
resp, err := ctx.client.Do(request)
|
|
if err != nil {
|
|
t.Fatalf("Failed to make request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusCreated {
|
|
postsResp := authClient.GetPosts(t)
|
|
if postsResp != nil {
|
|
t.Logf("Special characters accepted: %s (may be sanitized)", special)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("missing_required_fields", func(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"missing_url", map[string]any{"title": "Test"}},
|
|
{"empty_url", map[string]any{"title": "Test", "url": ""}},
|
|
{"missing_title_and_url", map[string]any{"content": "Content"}},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body, _ := json.Marshal(tc.body)
|
|
|
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
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.StatusCreated {
|
|
t.Errorf("Expected missing required fields to be rejected")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("wrong_data_types", func(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"title_as_number", `{"title": 123, "url": "https://example.com"}`},
|
|
{"url_as_boolean", `{"title": "Test", "url": true}`},
|
|
{"content_as_array", `{"title": "Test", "url": "https://example.com", "content": []}`},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader([]byte(tc.body)))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
|
testutils.WithStandardHeaders(request)
|
|
|
|
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.StatusCreated {
|
|
t.Errorf("Expected wrong data types to be rejected")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestE2E_ConcurrentVotes(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("concurrent_votes", func(t *testing.T) {
|
|
user1 := ctx.createUserWithCleanup(t, "concvote1", "Password123!")
|
|
user2 := ctx.createUserWithCleanup(t, "concvote2", "Password123!")
|
|
user3 := ctx.createUserWithCleanup(t, "concvote3", "Password123!")
|
|
|
|
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
|
post := client1.CreatePost(t, "Concurrent Vote Test", "https://example.com/concvote", "Content")
|
|
|
|
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
|
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
|
|
|
var wg sync.WaitGroup
|
|
results := make(chan bool, 3)
|
|
|
|
wg.Add(3)
|
|
go func() {
|
|
defer wg.Done()
|
|
voteResp := client1.VoteOnPost(t, post.ID, "up")
|
|
results <- voteResp.Success
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
voteResp := client2.VoteOnPost(t, post.ID, "up")
|
|
results <- voteResp.Success
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
voteResp := client3.VoteOnPost(t, post.ID, "down")
|
|
results <- voteResp.Success
|
|
}()
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
successCount := 0
|
|
for success := range results {
|
|
if success {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount == 0 {
|
|
t.Errorf("Expected at least some concurrent votes to succeed")
|
|
}
|
|
|
|
var dbPost database.Post
|
|
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err != nil {
|
|
t.Fatalf("Failed to find post in database: %v", err)
|
|
}
|
|
|
|
if dbPost.UpVotes+dbPost.DownVotes != successCount {
|
|
t.Logf("Vote counts may not match exactly due to race conditions (acceptable)")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ConcurrentPostCreation(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("concurrent_post_creation", func(t *testing.T) {
|
|
users := ctx.createMultipleUsersWithCleanup(t, 5, "concpost", "Password123!")
|
|
|
|
var wg sync.WaitGroup
|
|
results := make(chan *TestPost, len(users))
|
|
var mu sync.Mutex
|
|
createdURLs := make(map[string]bool)
|
|
|
|
for _, user := range users {
|
|
u := user
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
client, err := ctx.loginUserSafe(t, u.Username, u.Password)
|
|
if err != nil || client == nil {
|
|
results <- nil
|
|
return
|
|
}
|
|
|
|
url := fmt.Sprintf("https://example.com/concpost/%d", u.ID)
|
|
mu.Lock()
|
|
if createdURLs[url] {
|
|
mu.Unlock()
|
|
results <- nil
|
|
return
|
|
}
|
|
createdURLs[url] = true
|
|
mu.Unlock()
|
|
|
|
post, err := client.CreatePostSafe("Concurrent Post", url, "Content")
|
|
results <- post
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
successCount := 0
|
|
for post := range results {
|
|
if post != nil && post.ID != 0 {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount == 0 {
|
|
t.Errorf("Expected at least some concurrent post creations to succeed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ConcurrentProfileUpdates(t *testing.T) {
|
|
ctx := setupTestContext(t)
|
|
|
|
t.Run("concurrent_profile_updates", func(t *testing.T) {
|
|
createdUser := ctx.createUserWithCleanup(t, "concprofile", "Password123!")
|
|
|
|
client1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
|
client2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
|
|
|
var wg sync.WaitGroup
|
|
results := make(chan bool, 2)
|
|
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
newUsername := uniqueUsername(t, "update1")
|
|
client1.UpdateUsername(t, newUsername)
|
|
profile := client1.GetProfile(t)
|
|
results <- (profile != nil && profile.Data.Username == newUsername)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
newUsername := uniqueUsername(t, "update2")
|
|
client2.UpdateUsername(t, newUsername)
|
|
profile := client2.GetProfile(t)
|
|
results <- (profile != nil && profile.Data.Username == newUsername)
|
|
}()
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
successCount := 0
|
|
for success := range results {
|
|
if success {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount == 0 {
|
|
t.Errorf("Expected at least some concurrent profile updates to succeed")
|
|
}
|
|
})
|
|
}
|