To gitea and beyond, let's go(-yco)
This commit is contained in:
271
internal/e2e/api_documentation_test.go
Normal file
271
internal/e2e/api_documentation_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SwaggerDocumentation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("swagger_json_is_valid", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("Swagger JSON not available (status %d)", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
if swaggerDoc["swagger"] == nil && swaggerDoc["openapi"] == nil {
|
||||
t.Error("Swagger JSON missing swagger/openapi version")
|
||||
}
|
||||
|
||||
if swaggerDoc["info"] == nil {
|
||||
t.Error("Swagger JSON missing info section")
|
||||
}
|
||||
|
||||
if swaggerDoc["paths"] == nil {
|
||||
t.Error("Swagger JSON missing paths section")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("swagger_yaml_is_valid", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.yaml", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Swagger YAML endpoint returned status %d (may not be available)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api_endpoints_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Paths section is not a map")
|
||||
return
|
||||
}
|
||||
|
||||
requiredPaths := []string{
|
||||
"/api",
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/me",
|
||||
"/api/posts",
|
||||
}
|
||||
|
||||
for _, requiredPath := range requiredPaths {
|
||||
if paths[requiredPath] == nil {
|
||||
t.Errorf("Required endpoint %s not documented", requiredPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request_response_schemas_present", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
definitions, ok := swaggerDoc["definitions"].(map[string]interface{})
|
||||
if !ok {
|
||||
definitions, ok = swaggerDoc["components"].(map[string]interface{})
|
||||
if ok {
|
||||
definitions, _ = definitions["schemas"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
if definitions == nil {
|
||||
t.Log("No definitions/schemas section found (may use inline schemas)")
|
||||
return
|
||||
}
|
||||
|
||||
if len(definitions) == 0 {
|
||||
t.Error("Definitions/schemas section is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("swagger_ui_accessible", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/index.html", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Swagger UI returned status %d (may not be available)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_APIEndpointDocumentation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("api_info_endpoint_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
apiPath, ok := paths["/api"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("API endpoint not documented")
|
||||
return
|
||||
}
|
||||
|
||||
getMethod, ok := apiPath["get"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("API GET method not documented")
|
||||
return
|
||||
}
|
||||
|
||||
if getMethod["responses"] == nil {
|
||||
t.Error("API endpoint missing responses")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth_endpoints_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
authEndpoints := []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
}
|
||||
|
||||
for _, endpoint := range authEndpoints {
|
||||
endpointData, ok := paths[endpoint].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Auth endpoint %s not documented", endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
postMethod, ok := endpointData["post"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Auth endpoint %s missing POST method", endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
if postMethod["parameters"] == nil && postMethod["requestBody"] == nil {
|
||||
t.Logf("Auth endpoint %s may use inline request body", endpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
1683
internal/e2e/auth_test.go
Normal file
1683
internal/e2e/auth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1191
internal/e2e/common.go
Normal file
1191
internal/e2e/common.go
Normal file
File diff suppressed because it is too large
Load Diff
258
internal/e2e/consistency_test.go
Normal file
258
internal/e2e/consistency_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
func TestE2E_VoteCountConsistency(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_count_consistency", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "voteuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "voteuser2", "Password123!")
|
||||
user3 := ctx.createUserWithCleanup(t, "voteuser3", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Vote Count Test", "https://example.com/votecount", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
client3.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var voteCount int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND type = ?", post.ID, database.VoteUp).Count(&voteCount)
|
||||
if voteCount != int64(dbPost.UpVotes) {
|
||||
t.Errorf("Expected upvote count %d to match database count %d", dbPost.UpVotes, voteCount)
|
||||
}
|
||||
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND type = ?", post.ID, database.VoteDown).Count(&voteCount)
|
||||
if voteCount != int64(dbPost.DownVotes) {
|
||||
t.Errorf("Expected downvote count %d to match database count %d", dbPost.DownVotes, voteCount)
|
||||
}
|
||||
|
||||
postsResp := client1.GetPosts(t)
|
||||
apiPost := findPostInList(postsResp, post.ID)
|
||||
if apiPost == nil {
|
||||
t.Fatalf("Expected to find post in API response")
|
||||
}
|
||||
if apiPost.UpVotes != dbPost.UpVotes {
|
||||
t.Errorf("Expected API upvote count %d to match database %d", apiPost.UpVotes, dbPost.UpVotes)
|
||||
}
|
||||
if apiPost.DownVotes != dbPost.DownVotes {
|
||||
t.Errorf("Expected API downvote count %d to match database %d", apiPost.DownVotes, dbPost.DownVotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostScoreCalculation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_score_calculation", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "scoreuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "scoreuser2", "Password123!")
|
||||
user3 := ctx.createUserWithCleanup(t, "scoreuser3", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Score Test", "https://example.com/score", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
client3.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
expectedScore := dbPost.UpVotes - dbPost.DownVotes
|
||||
if dbPost.Score != expectedScore {
|
||||
t.Errorf("Expected score %d (upvotes %d - downvotes %d), got %d", expectedScore, dbPost.UpVotes, dbPost.DownVotes, dbPost.Score)
|
||||
}
|
||||
|
||||
postsResp := client1.GetPosts(t)
|
||||
apiPost := findPostInList(postsResp, post.ID)
|
||||
if apiPost == nil {
|
||||
t.Fatalf("Expected to find post in API response")
|
||||
}
|
||||
if apiPost.Score != expectedScore {
|
||||
t.Errorf("Expected API score %d to match calculated score %d", apiPost.Score, expectedScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostDeletionCascades(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_deletion_cascades", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "cascadeuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "cascadeuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Cascade Test", "https://example.com/cascade", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountBefore)
|
||||
if voteCountBefore == 0 {
|
||||
t.Fatalf("Expected votes to exist before deletion")
|
||||
}
|
||||
|
||||
client1.DeletePost(t, post.ID)
|
||||
|
||||
var voteCountAfter int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountAfter)
|
||||
if voteCountAfter != 0 {
|
||||
t.Errorf("Expected votes to be deleted after post deletion, found %d votes", voteCountAfter)
|
||||
}
|
||||
|
||||
var dbPost database.Post
|
||||
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err == nil {
|
||||
t.Errorf("Expected post to be deleted from database")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_UserDeletionCascades(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_deletion_cascades", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "deleteuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "deleteuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post1 := client1.CreatePost(t, "Post 1", "https://example.com/post1", "Content 1")
|
||||
post2 := client1.CreatePost(t, "Post 2", "https://example.com/post2", "Content 2")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post1.ID, "up")
|
||||
|
||||
var postCountBefore int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCountBefore)
|
||||
if postCountBefore == 0 {
|
||||
t.Fatalf("Expected posts to exist before deletion")
|
||||
}
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id IN (?)", []uint{post1.ID, post2.ID}).Count(&voteCountBefore)
|
||||
if voteCountBefore == 0 {
|
||||
t.Fatalf("Expected votes to exist before deletion")
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
client1.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
client1.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
var postCountAfter int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCountAfter)
|
||||
if postCountAfter != 0 {
|
||||
t.Errorf("Expected posts to be deleted after user deletion, found %d posts", postCountAfter)
|
||||
}
|
||||
|
||||
var voteCountAfter int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id IN (?)", []uint{post1.ID, post2.ID}).Count(&voteCountAfter)
|
||||
if voteCountAfter != 0 {
|
||||
t.Errorf("Expected votes to be deleted after post deletion, found %d votes", voteCountAfter)
|
||||
}
|
||||
|
||||
var dbUser database.User
|
||||
if err := ctx.server.DB.First(&dbUser, user1.ID).Error; err == nil {
|
||||
t.Errorf("Expected user to be deleted from database")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ReferentialIntegrity(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("referential_integrity", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "refuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "refuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Ref Integrity Test", "https://example.com/ref", "Content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
|
||||
var voteCount int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND user_id = ?", post.ID, user2.ID).Count(&voteCount)
|
||||
if voteCount != 1 {
|
||||
t.Errorf("Expected vote to exist with correct foreign keys")
|
||||
}
|
||||
|
||||
var postCount int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCount)
|
||||
if postCount == 0 {
|
||||
t.Errorf("Expected post to exist with correct author foreign key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_OrphanedRecordsPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("orphaned_records_prevention", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "orphanuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "orphanuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Orphan Test", "https://example.com/orphan", "Content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountBefore)
|
||||
|
||||
client1.DeletePost(t, post.ID)
|
||||
|
||||
var orphanedVotes int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&orphanedVotes)
|
||||
if orphanedVotes != 0 {
|
||||
t.Errorf("Expected no orphaned votes after post deletion, found %d", orphanedVotes)
|
||||
}
|
||||
|
||||
post2 := client1.CreatePost(t, "Orphan Test 2", "https://example.com/orphan2", "Content")
|
||||
client2.VoteOnPost(t, post2.ID, "up")
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
client1.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
client1.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
var orphanedPosts int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&orphanedPosts)
|
||||
if orphanedPosts != 0 {
|
||||
t.Errorf("Expected no posts with author_id = %d after user deletion, found %d", user1.ID, orphanedPosts)
|
||||
}
|
||||
|
||||
var orphanedVotesAfter int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Vote{}).Where("post_id = ?", post2.ID).Count(&orphanedVotesAfter)
|
||||
if orphanedVotesAfter != 0 {
|
||||
t.Errorf("Expected no orphaned votes after post deletion via user deletion, found %d", orphanedVotesAfter)
|
||||
}
|
||||
})
|
||||
}
|
||||
216
internal/e2e/deployment_test.go
Normal file
216
internal/e2e/deployment_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_DockerDeployment(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping Docker deployment tests in short mode")
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Skip("Could not find workspace root")
|
||||
return
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
t.Run("dockerfile_exists", func(t *testing.T) {
|
||||
dockerfilePath := filepath.Join(workspaceRoot, "Dockerfile")
|
||||
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Dockerfile not found at %s", dockerfilePath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dockerfile_valid", func(t *testing.T) {
|
||||
dockerfilePath := filepath.Join(workspaceRoot, "Dockerfile")
|
||||
content, err := os.ReadFile(dockerfilePath)
|
||||
if err != nil {
|
||||
t.Skipf("Failed to read Dockerfile: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
required := []string{
|
||||
"FROM",
|
||||
"WORKDIR",
|
||||
"COPY",
|
||||
"RUN",
|
||||
"EXPOSE",
|
||||
}
|
||||
|
||||
for _, req := range required {
|
||||
if !strings.Contains(contentStr, req) {
|
||||
t.Errorf("Dockerfile missing required directive: %s", req)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service_file_exists", func(t *testing.T) {
|
||||
servicePath := filepath.Join(workspaceRoot, "services/goyco.service")
|
||||
if _, err := os.Stat(servicePath); os.IsNotExist(err) {
|
||||
t.Skipf("Service file not found at %s", servicePath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service_file_valid", func(t *testing.T) {
|
||||
servicePath := filepath.Join(workspaceRoot, "services/goyco.service")
|
||||
content, err := os.ReadFile(servicePath)
|
||||
if err != nil {
|
||||
t.Skipf("Failed to read service file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
required := []string{
|
||||
"[Unit]",
|
||||
"[Service]",
|
||||
"ExecStart",
|
||||
"Restart",
|
||||
}
|
||||
|
||||
for _, req := range required {
|
||||
if !strings.Contains(contentStr, req) {
|
||||
t.Errorf("Service file missing required section: %s", req)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_files_exist", func(t *testing.T) {
|
||||
staticDir := filepath.Join(workspaceRoot, "internal/static")
|
||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||
t.Skipf("Static directory not found at %s", staticDir)
|
||||
return
|
||||
}
|
||||
|
||||
requiredFiles := []string{
|
||||
"robots.txt",
|
||||
}
|
||||
|
||||
for _, file := range requiredFiles {
|
||||
filePath := filepath.Join(staticDir, file)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
t.Errorf("Required static file not found: %s", filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("templates_exist", func(t *testing.T) {
|
||||
templatesDir := filepath.Join(workspaceRoot, "internal/templates")
|
||||
if _, err := os.Stat(templatesDir); os.IsNotExist(err) {
|
||||
t.Skipf("Templates directory not found at %s", templatesDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_EnvironmentVariables(t *testing.T) {
|
||||
t.Run("config_loading", func(t *testing.T) {
|
||||
envVars := []string{
|
||||
"SERVER_HOST",
|
||||
"SERVER_PORT",
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
"DATABASE_NAME",
|
||||
"JWT_SECRET",
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
if os.Getenv(envVar) == "" {
|
||||
t.Logf("Environment variable %s not set (this is expected in test environment)", envVar)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_BinaryExists(t *testing.T) {
|
||||
t.Run("binary_builds", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping binary build test in short mode")
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skipf("Failed to get working directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Skip("Could not find workspace root")
|
||||
return
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", "/tmp/goyco-test", "./cmd/goyco")
|
||||
cmd.Dir = workspaceRoot
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Skipf("Failed to build binary: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat("/tmp/goyco-test"); os.IsNotExist(err) {
|
||||
t.Error("Binary was not created")
|
||||
} else {
|
||||
os.Remove("/tmp/goyco-test")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConfigurationValidation(t *testing.T) {
|
||||
t.Run("required_paths", func(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Fatalf("Could not find workspace root (go.mod) starting from %s", wd)
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
requiredPaths := []string{
|
||||
"cmd/goyco",
|
||||
"internal",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
}
|
||||
|
||||
for _, path := range requiredPaths {
|
||||
fullPath := filepath.Join(workspaceRoot, path)
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
t.Errorf("Required path not found: %s (workspace root: %s)", path, workspaceRoot)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
507
internal/e2e/error_handling_test.go
Normal file
507
internal/e2e/error_handling_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
364
internal/e2e/error_recovery_test.go
Normal file
364
internal/e2e/error_recovery_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_DatabaseFailureRecovery(t *testing.T) {
|
||||
t.Run("database_unavailable_handles_gracefully", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
sqlDB.Close()
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Logf("Expected 500 or 503, got %d (acceptable for unavailable DB)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connection_pool_exhaustion", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
originalMaxOpen := sqlDB.Stats().MaxOpenConnections
|
||||
if originalMaxOpen == 0 {
|
||||
originalMaxOpen = 1
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(2)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := sqlDB.Conn(context.Background())
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
errorCount := 0
|
||||
for range errors {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount == 0 {
|
||||
t.Log("No connection errors occurred (pool handled load)")
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(int(originalMaxOpen))
|
||||
})
|
||||
|
||||
t.Run("transaction_rollback_on_error", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "rollbackuser", "StrongPass123!")
|
||||
|
||||
tx := ctx.server.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("Failed to begin transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Rollback Test Post",
|
||||
URL: "https://example.com/rollback",
|
||||
Content: "This post should be rolled back",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx.Create(post).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to create post in transaction: %v", err)
|
||||
}
|
||||
|
||||
var postInTx database.Post
|
||||
err = tx.First(&postInTx, post.ID).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to retrieve post in transaction: %v", err)
|
||||
}
|
||||
|
||||
tx.Rollback()
|
||||
|
||||
var postAfterRollback database.Post
|
||||
err = ctx.server.DB.First(&postAfterRollback, post.ID).Error
|
||||
if err == nil {
|
||||
t.Error("Expected post to not exist after transaction rollback")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Logf("Post correctly not found after rollback (error: %v)", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transaction_commit_succeeds", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "commituser", "StrongPass123!")
|
||||
|
||||
tx := ctx.server.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("Failed to begin transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Commit Test Post",
|
||||
URL: "https://example.com/commit",
|
||||
Content: "This post should be committed",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx.Create(post).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to create post in transaction: %v", err)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to commit transaction: %v", err)
|
||||
}
|
||||
|
||||
var postAfterCommit database.Post
|
||||
err = ctx.server.DB.First(&postAfterCommit, post.ID).Error
|
||||
if err != nil {
|
||||
t.Errorf("Expected post to exist after transaction commit, got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("database_timeout_handling", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
ctxTimeout, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
conn, err := sqlDB.Conn(ctxTimeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rows, err := conn.QueryContext(ctxTimeout, "SELECT 1")
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("Timeout handled correctly: %v", err)
|
||||
}
|
||||
if rows != nil {
|
||||
rows.Close()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent_transaction_isolation", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "isolationuser", "StrongPass123!")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 2)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tx1 := ctx.server.DB.Begin()
|
||||
if tx1.Error != nil {
|
||||
errors <- tx1.Error
|
||||
return
|
||||
}
|
||||
|
||||
post1 := &database.Post{
|
||||
Title: "Isolation Post 1",
|
||||
URL: "https://example.com/isolation1",
|
||||
Content: "First transaction",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx1.Create(post1).Error
|
||||
if err != nil {
|
||||
tx1.Rollback()
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
tx1.Commit()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
|
||||
tx2 := ctx.server.DB.Begin()
|
||||
if tx2.Error != nil {
|
||||
errors <- tx2.Error
|
||||
return
|
||||
}
|
||||
|
||||
post2 := &database.Post{
|
||||
Title: "Isolation Post 2",
|
||||
URL: "https://example.com/isolation2",
|
||||
Content: "Second transaction",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx2.Create(post2).Error
|
||||
if err != nil {
|
||||
tx2.Rollback()
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
tx2.Commit()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Errorf("Transaction error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_DatabaseConnectionPool(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("pool_stats_tracking", func(t *testing.T) {
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
stats := sqlDB.Stats()
|
||||
if stats.MaxOpenConnections == 0 {
|
||||
t.Error("Expected MaxOpenConnections to be set")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
newStats := sqlDB.Stats()
|
||||
if newStats.OpenConnections > stats.OpenConnections {
|
||||
t.Logf("Connection pool used: %d -> %d connections", stats.OpenConnections, newStats.OpenConnections)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pool_reuses_connections", func(t *testing.T) {
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
initialStats := sqlDB.Stats()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
finalStats := sqlDB.Stats()
|
||||
if finalStats.OpenConnections > initialStats.MaxOpenConnections {
|
||||
t.Errorf("Pool exceeded max connections: %d > %d", finalStats.OpenConnections, initialStats.MaxOpenConnections)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_DatabaseErrorHandling(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("invalid_query_returns_error", func(t *testing.T) {
|
||||
var result struct {
|
||||
ID int
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Raw("SELECT * FROM nonexistent_table WHERE id = ?", 1).Scan(&result).Error
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid query")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("constraint_violation_handled", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "constraintuser", "StrongPass123!")
|
||||
|
||||
duplicateUser := &database.User{
|
||||
Username: testUser.Username,
|
||||
Email: "different@example.com",
|
||||
Password: "DifferentPass123!",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Create(duplicateUser).Error
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate username")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null_constraint_violation", func(t *testing.T) {
|
||||
invalidPost := &database.Post{
|
||||
Title: "",
|
||||
URL: "",
|
||||
Content: "",
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Create(invalidPost).Error
|
||||
if err == nil {
|
||||
t.Log("SQLite allows empty strings (constraint validation handled at application level)")
|
||||
} else {
|
||||
t.Logf("Database rejected empty values: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
327
internal/e2e/middleware_test.go
Normal file
327
internal/e2e/middleware_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_CompressionMiddleware(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("compression_enabled_with_accept_encoding", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentEncoding := resp.Header.Get("Content-Encoding")
|
||||
if contentEncoding == "gzip" {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if isGzipCompressed(body) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress: %v", err)
|
||||
}
|
||||
|
||||
if len(decompressed) == 0 {
|
||||
t.Error("Decompressed body is empty")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Logf("Compression not applied (Content-Encoding: %s)", contentEncoding)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_compression_without_accept_encoding", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentEncoding := resp.Header.Get("Content-Encoding")
|
||||
if contentEncoding == "gzip" {
|
||||
t.Error("Expected no compression without Accept-Encoding header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decompression_handles_gzip_request", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "compressionuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
postData := `{"title":"Compressed Post","url":"https://example.com/compressed","content":"Test content"}`
|
||||
gz.Write([]byte(postData))
|
||||
gz.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
t.Log("Decompression middleware rejected invalid gzip")
|
||||
} else if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
t.Log("Decompression middleware handled gzip request successfully")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_CacheMiddleware(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("cache_miss_then_hit", func(t *testing.T) {
|
||||
req1, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req1)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
cacheStatus1 := resp1.Header.Get("X-Cache")
|
||||
if cacheStatus1 == "HIT" {
|
||||
t.Log("First request was cached (unexpected but acceptable)")
|
||||
}
|
||||
|
||||
req2, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req2)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
cacheStatus2 := resp2.Header.Get("X-Cache")
|
||||
if cacheStatus2 == "HIT" {
|
||||
t.Log("Second request was served from cache")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache_invalidation_on_post", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "cacheuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
req1, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
postData := `{"title":"Cache Invalidation Test","url":"https://example.com/cache","content":"Test"}`
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
req3, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req3)
|
||||
req3.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp3, err := ctx.client.Do(req3)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
|
||||
cacheStatus := resp3.Header.Get("X-Cache")
|
||||
if cacheStatus == "HIT" {
|
||||
t.Log("Cache was invalidated after POST")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_CSRFProtection(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("csrf_protection_for_non_api_routes", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/auth/login", strings.NewReader(`{"username":"test","password":"test"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Log("CSRF protection active for non-API routes")
|
||||
} else {
|
||||
t.Logf("CSRF check result: status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csrf_bypass_for_api_routes", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "csrfuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
postData := `{"title":"CSRF Test","url":"https://example.com/csrf","content":"Test"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Error("API routes should bypass CSRF protection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csrf_allows_get_requests", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/auth/login", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Error("GET requests should not require CSRF token")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RequestSizeLimit(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("request_within_size_limit", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
smallData := strings.Repeat("a", 100)
|
||||
postData := `{"title":"` + smallData + `","url":"https://example.com","content":"test"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusRequestEntityTooLarge {
|
||||
t.Error("Small request should not exceed size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request_exceeds_size_limit", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser2", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
largeData := strings.Repeat("a", 2*1024*1024)
|
||||
postData := `{"title":"test","url":"https://example.com","content":"` + largeData + `"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusRequestEntityTooLarge {
|
||||
t.Log("Request size limit enforced correctly")
|
||||
} else {
|
||||
t.Logf("Request size limit check result: status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isGzipCompressed(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
375
internal/e2e/performance_test.go
Normal file
375
internal/e2e/performance_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_Performance(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("response_times", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "perfuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
req func() (*http.Request, error)
|
||||
}{
|
||||
{
|
||||
name: "health",
|
||||
req: func() (*http.Request, error) {
|
||||
return http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "posts_list",
|
||||
req: func() (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err == nil {
|
||||
testutils.WithStandardHeaders(req)
|
||||
}
|
||||
return req, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
req: func() (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(req)
|
||||
}
|
||||
return req, err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
var totalTime time.Duration
|
||||
iterations := 10
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
req, err := endpoint.req()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := ctx.client.Do(req)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalTime += duration
|
||||
}
|
||||
|
||||
avgTime := totalTime / time.Duration(iterations)
|
||||
if avgTime > 500*time.Millisecond {
|
||||
t.Errorf("Average response time %v exceeds 500ms", avgTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent_requests", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "concurrentperf", "StrongPass123!")
|
||||
|
||||
concurrency := 20
|
||||
requestsPerGoroutine := 5
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < requestsPerGoroutine; j++ {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
totalRequests := int64(concurrency * requestsPerGoroutine)
|
||||
if successCount < totalRequests*8/10 {
|
||||
t.Errorf("Expected at least 80%% success rate, got %d/%d successful", successCount, totalRequests)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("database_query_performance", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "dbperf", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Post %d", i), fmt.Sprintf("https://example.com/%d", i), "Content")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
postsResp := authClient.GetPosts(t)
|
||||
duration := time.Since(start)
|
||||
|
||||
if len(postsResp.Data.Posts) < 10 {
|
||||
t.Errorf("Expected at least 10 posts, got %d", len(postsResp.Data.Posts))
|
||||
}
|
||||
|
||||
if duration > 1*time.Second {
|
||||
t.Errorf("Posts query took %v, expected under 1s", duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("memory_usage", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "memuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
initialPosts := 50
|
||||
for i := 0; i < initialPosts; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Memory Test Post %d", i), fmt.Sprintf("https://example.com/mem%d", i), "Content")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts?limit=100", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
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.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var postsResp testutils.PostsListResponse
|
||||
reader := resp.Body
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
reader = gzReader
|
||||
}
|
||||
if err := json.NewDecoder(reader).Decode(&postsResp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(postsResp.Data.Posts) < initialPosts {
|
||||
t.Errorf("Expected at least %d posts, got %d", initialPosts, len(postsResp.Data.Posts))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LoadTest(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("sustained_load", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "loaduser", "StrongPass123!")
|
||||
|
||||
duration := 5 * time.Second
|
||||
requestsPerSecond := 10
|
||||
ticker := time.NewTicker(time.Second / time.Duration(requestsPerSecond))
|
||||
defer ticker.Stop()
|
||||
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
done := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(duration)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
totalRequests := successCount + errorCount
|
||||
if totalRequests == 0 {
|
||||
t.Error("No requests were made")
|
||||
return
|
||||
}
|
||||
successRate := float64(successCount) / float64(totalRequests)
|
||||
if successRate < 0.9 {
|
||||
t.Errorf("Success rate %.2f%% below 90%% threshold", successRate*100)
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
go func() {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentWrites(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_post_creation", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 5, "writeuser", "StrongPass123!")
|
||||
var wg sync.WaitGroup
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
|
||||
for _, user := range users {
|
||||
u := user
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
authClient, err := ctx.loginUserSafe(t, u.Username, u.Password)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
post, err := authClient.CreatePostSafe(
|
||||
fmt.Sprintf("Concurrent Post %d", i),
|
||||
fmt.Sprintf("https://example.com/concurrent%d-%d", u.ID, i),
|
||||
"Content",
|
||||
)
|
||||
if err == nil && post != nil {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expectedPosts := int64(len(users) * 5)
|
||||
if successCount < expectedPosts*7/10 {
|
||||
t.Errorf("Expected at least 70%% success rate, got %d/%d successful (errors: %d)", successCount, expectedPosts, errorCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ResponseSize(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("large_response", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sizetest", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Post %d", i), fmt.Sprintf("https://example.com/%d", i), "Content")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(resp.Body)
|
||||
responseSize := buf.Len()
|
||||
|
||||
if responseSize > 10*1024*1024 {
|
||||
t.Errorf("Response size %d bytes exceeds 10MB limit", responseSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Throughput(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("requests_per_second", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "throughput", "StrongPass123!")
|
||||
|
||||
duration := 3 * time.Second
|
||||
start := time.Now()
|
||||
var requestCount int64
|
||||
|
||||
for time.Since(start) < duration {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
atomic.AddInt64(&requestCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
rps := float64(requestCount) / elapsed.Seconds()
|
||||
|
||||
if rps < 10 {
|
||||
t.Errorf("Throughput %.2f req/s below 10 req/s threshold", rps)
|
||||
}
|
||||
})
|
||||
}
|
||||
108
internal/e2e/posts_test.go
Normal file
108
internal/e2e/posts_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_PostManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_crud_operations", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Original Post", "https://example.com/original", "Original content")
|
||||
updatedPost := authClient.UpdatePost(t, createdPost.ID, "Updated Post", "https://example.com/updated", "Updated content")
|
||||
|
||||
if updatedPost.Title != "Updated Post" {
|
||||
t.Errorf("Expected updated title 'Updated Post', got '%s'", updatedPost.Title)
|
||||
}
|
||||
if updatedPost.Content != "Updated content" {
|
||||
t.Errorf("Expected updated content 'Updated content', got '%s'", updatedPost.Content)
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
assertPostInList(t, postsResp, updatedPost)
|
||||
|
||||
authClient.DeletePost(t, createdPost.ID)
|
||||
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
if len(finalPostsResp.Data.Posts) > 0 {
|
||||
for _, post := range finalPostsResp.Data.Posts {
|
||||
if post.ID == createdPost.ID {
|
||||
t.Errorf("Expected post to be deleted, but it still appears in posts list")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostOwnershipAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_ownership_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "user", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
createdPost := authClient1.CreatePost(t, "User1's Post", "https://example.com/user1", "This is user1's post content")
|
||||
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
t.Run("user2_cannot_update_user1_post", func(t *testing.T) {
|
||||
statusCode := authClient2.UpdatePostExpectStatus(t, createdPost.ID, "Hacked Title", "https://evil.com", "Hacked content")
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 403 Forbidden when User2 tries to update User1's post, got %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user2_cannot_delete_user1_post", func(t *testing.T) {
|
||||
statusCode := authClient2.DeletePostExpectStatus(t, createdPost.ID)
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 403 Forbidden when User2 tries to delete User1's post, got %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_post_unchanged", func(t *testing.T) {
|
||||
postsResp := authClient1.GetPosts(t)
|
||||
found := false
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == createdPost.ID {
|
||||
found = true
|
||||
if post.Title != createdPost.Title {
|
||||
t.Errorf("Expected post title to remain '%s', but it was modified to '%s'", createdPost.Title, post.Title)
|
||||
}
|
||||
if post.Content != createdPost.Content {
|
||||
t.Errorf("Expected post content to remain unchanged, but it was modified")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected User1's post to still exist, but it was not found in the posts list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_can_update_own_post", func(t *testing.T) {
|
||||
updatedPost := authClient1.UpdatePost(t, createdPost.ID, "Updated by User1", "https://example.com/updated", "Updated content by User1")
|
||||
if updatedPost.Title != "Updated by User1" {
|
||||
t.Errorf("Expected post title to be 'Updated by User1', got '%s'", updatedPost.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_can_delete_own_post", func(t *testing.T) {
|
||||
deletablePost := authClient1.CreatePost(t, "Deletable Post", "https://example.com/deletable", "This post will be deleted")
|
||||
authClient1.DeletePost(t, deletablePost.ID)
|
||||
|
||||
postsResp := authClient1.GetPosts(t)
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == deletablePost.ID {
|
||||
t.Errorf("Expected post %d to be deleted, but it still exists", deletablePost.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
254
internal/e2e/rate_limiting_test.go
Normal file
254
internal/e2e/rate_limiting_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_RateLimitingHeaders(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 3)
|
||||
|
||||
t.Run("rate_limit_headers_present", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "ratelimituser", "StrongPass123!")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testutils.GenerateTestIP())
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
t.Error("Expected Retry-After header when rate limited")
|
||||
}
|
||||
|
||||
var jsonResponse map[string]interface{}
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
_ = json.Unmarshal(body, &jsonResponse)
|
||||
|
||||
if resp.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("Expected Content-Type to be application/json")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limit_exceeded_response", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "ratelimituser2", "StrongPass123!")
|
||||
testIP := testutils.GenerateTestIP()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if i >= 3 {
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429 on request %d, got %d", i+1, resp.StatusCode)
|
||||
} else {
|
||||
var errorResponse map[string]interface{}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(body, &errorResponse); err == nil {
|
||||
if errorResponse["error"] == nil {
|
||||
t.Error("Expected error field in rate limit response")
|
||||
}
|
||||
if errorResponse["retry_after"] == nil {
|
||||
t.Error("Expected retry_after field in rate limit response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RateLimitResetBehavior(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 2)
|
||||
|
||||
t.Run("rate_limit_resets_after_window", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "resetuser", "StrongPass123!")
|
||||
testIP := testutils.GenerateTestIP()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
t.Log("Rate limit correctly enforced")
|
||||
}
|
||||
|
||||
ctx.assertEventually(t, func() bool {
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
return resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized
|
||||
}, 70*time.Second)
|
||||
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized {
|
||||
t.Log("Rate limit reset after window")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RateLimitDifferentScenarios(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 5)
|
||||
|
||||
t.Run("different_ips_have_separate_limits", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "multiuser", "StrongPass123!")
|
||||
|
||||
ip1 := testutils.GenerateTestIP()
|
||||
ip2 := testutils.GenerateTestIP()
|
||||
|
||||
successCount1 := 0
|
||||
successCount2 := 0
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
req1, _ := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("X-Forwarded-For", ip1)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err == nil {
|
||||
if resp1.StatusCode == http.StatusOK || resp1.StatusCode == http.StatusUnauthorized {
|
||||
successCount1++
|
||||
}
|
||||
resp1.Body.Close()
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", ip2)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err == nil {
|
||||
if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized {
|
||||
successCount2++
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if successCount1 > 0 && successCount2 > 0 {
|
||||
t.Log("Different IPs have separate rate limits")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authenticated_users_have_separate_limits", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "authuser1", "StrongPass123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "authuser2", "StrongPass123!")
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
authClient2 := ctx.loginUser(t, user2.Username, "StrongPass123!")
|
||||
|
||||
successCount1 := 0
|
||||
successCount2 := 0
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
req1, _ := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("Authorization", "Bearer "+authClient1.Token)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err == nil {
|
||||
if resp1.StatusCode == http.StatusOK {
|
||||
successCount1++
|
||||
}
|
||||
resp1.Body.Close()
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("Authorization", "Bearer "+authClient2.Token)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err == nil {
|
||||
if resp2.StatusCode == http.StatusOK {
|
||||
successCount2++
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if successCount1 > 5 && successCount2 > 5 {
|
||||
t.Log("Authenticated users have separate rate limits")
|
||||
}
|
||||
})
|
||||
}
|
||||
167
internal/e2e/robots_txt_test.go
Normal file
167
internal/e2e/robots_txt_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_RobotsTxt(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("robots_txt_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for robots.txt, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/plain") && !strings.Contains(contentType, "text") {
|
||||
t.Logf("Unexpected Content-Type for robots.txt: %s", contentType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if len(content) == 0 {
|
||||
t.Error("robots.txt is empty")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "User-agent") {
|
||||
t.Error("robots.txt missing User-agent directive")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_content_validation", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
hasUserAgent := false
|
||||
hasDisallow := false
|
||||
hasAllow := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "User-agent:") {
|
||||
hasUserAgent = true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Disallow:") {
|
||||
hasDisallow = true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Allow:") {
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUserAgent {
|
||||
t.Error("robots.txt missing User-agent directive")
|
||||
}
|
||||
|
||||
if !hasDisallow && !hasAllow {
|
||||
t.Log("robots.txt missing Allow/Disallow directives (may be intentional)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_api_disallowed", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if strings.Contains(content, "Disallow: /api/") {
|
||||
t.Log("robots.txt correctly disallows /api/")
|
||||
} else {
|
||||
t.Log("robots.txt may not explicitly disallow /api/")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_health_allowed", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if strings.Contains(content, "Allow: /health") {
|
||||
t.Log("robots.txt correctly allows /health")
|
||||
} else {
|
||||
t.Log("robots.txt may not explicitly allow /health")
|
||||
}
|
||||
})
|
||||
}
|
||||
602
internal/e2e/security_session_test.go
Normal file
602
internal/e2e/security_session_test.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SessionFixation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_fixation", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sessionfix", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldToken := authClient.Token
|
||||
oldRefreshToken := authClient.RefreshToken
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword456!")
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected old token to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: oldToken,
|
||||
RefreshToken: oldRefreshToken,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient.RefreshAccessToken(t)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Errorf("Expected old refresh token to be invalidated after password change, but refresh succeeded")
|
||||
}
|
||||
|
||||
newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword456!")
|
||||
if newAuthClient.Token == "" {
|
||||
t.Errorf("Expected to be able to login with new password")
|
||||
}
|
||||
|
||||
profile := newAuthClient.GetProfile(t)
|
||||
if profile.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected to access profile with new token, got username '%s'", profile.Data.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenInvalidationOnPasswordChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_invalidation_on_password_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "tokeninv", "Password123!")
|
||||
|
||||
authClient1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token1 := authClient1.Token
|
||||
refreshToken1 := authClient1.RefreshToken
|
||||
|
||||
authClient2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token2 := authClient2.Token
|
||||
refreshToken2 := authClient2.RefreshToken
|
||||
|
||||
authClient3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token3 := authClient3.Token
|
||||
refreshToken3 := authClient3.RefreshToken
|
||||
|
||||
profile1 := authClient1.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected token1 to work before password change")
|
||||
}
|
||||
|
||||
authClient1.UpdatePassword(t, "Password123!", "NewPassword789!")
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, token1)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token1 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, token2)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token2 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, token3)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token3 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient1 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token1,
|
||||
RefreshToken: refreshToken1,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient1.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken1 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient2 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token2,
|
||||
RefreshToken: refreshToken2,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient2.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken2 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient3 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token3,
|
||||
RefreshToken: refreshToken3,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient3.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken3 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword789!")
|
||||
if newAuthClient.Token == "" {
|
||||
t.Errorf("Expected to be able to login with new password")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenInvalidationOnEmailChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_invalidation_on_email_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "emailchange", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldToken := authClient.Token
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
authClient.UpdateEmail(t, uniqueEmail(t, "newemail"))
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Email change does not invalidate tokens (acceptable behavior)")
|
||||
}
|
||||
|
||||
_, statusCode = authClient.RefreshAccessToken(t)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Email change does not invalidate refresh tokens (acceptable behavior)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SessionVersionIncrements(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_version_increments", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sessionver", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
initialVersion := user.SessionVersion
|
||||
if initialVersion == 0 {
|
||||
t.Errorf("Expected initial session version to be >= 1, got %d", initialVersion)
|
||||
}
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword999!")
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after password change: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion <= initialVersion {
|
||||
t.Errorf("Expected session version to increment after password change, got %d (was %d)", user.SessionVersion, initialVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_OldTokensRejectedAfterSessionVersionChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("old_tokens_rejected_after_session_version_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "oldtoken", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
oldSessionVersion := user.SessionVersion
|
||||
oldToken := authClient.Token
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword888!")
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after password change: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion == oldSessionVersion {
|
||||
t.Errorf("Expected session version to change after password update")
|
||||
}
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected old token to be rejected after session version change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
tokenWithOldVersion := generateTokenWithSessionVersion(t, user, &cfg.JWT, oldSessionVersion)
|
||||
statusCode = ctx.makeRequestWithToken(t, tokenWithOldVersion)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token with old session version to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenRefreshWithOldSessionVersion(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_refresh_with_old_session_version", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "refreshold", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldRefreshToken := authClient.RefreshToken
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword777!")
|
||||
|
||||
oldClient := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: authClient.Token,
|
||||
RefreshToken: oldRefreshToken,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
|
||||
_, statusCode := oldClient.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh with old refresh token to fail after password change, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_MultiDeviceSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("multi_device_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "multidev", "Password123!")
|
||||
|
||||
deviceA := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
tokenA := deviceA.Token
|
||||
|
||||
deviceB := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
tokenB := deviceB.Token
|
||||
|
||||
profileA := deviceA.GetProfile(t)
|
||||
if profileA.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device A to access profile")
|
||||
}
|
||||
|
||||
profileB := deviceB.GetProfile(t)
|
||||
if profileB.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device B to access profile")
|
||||
}
|
||||
|
||||
deviceA.Logout(t)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, tokenA)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Logout may not invalidate tokens immediately (acceptable)")
|
||||
}
|
||||
|
||||
profileBAfter := deviceB.GetProfile(t)
|
||||
if profileBAfter.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device B to still work after device A logout")
|
||||
}
|
||||
|
||||
deviceB.RevokeAllTokens(t)
|
||||
|
||||
_, statusCode = deviceB.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device B refresh token to be revoked after revoke-all, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, tokenB)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Access token may still work after refresh token revocation (acceptable)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RevokeAllInvalidatesAllDevices(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("revoke_all_invalidates_all_devices", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "revokeall", "Password123!")
|
||||
|
||||
device1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken1 := device1.RefreshToken
|
||||
|
||||
device2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken2 := device2.RefreshToken
|
||||
|
||||
device3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken3 := device3.RefreshToken
|
||||
|
||||
device1.RevokeAllTokens(t)
|
||||
|
||||
oldDevice1 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device1.Token,
|
||||
RefreshToken: refreshToken1,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode := oldDevice1.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device1 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldDevice2 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device2.Token,
|
||||
RefreshToken: refreshToken2,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldDevice2.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device2 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldDevice3 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device3.Token,
|
||||
RefreshToken: refreshToken3,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldDevice3.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device3 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenTiming(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_timing", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "timing", "Password123!")
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
t.Run("token_just_before_expiry", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, 1*time.Minute)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected token just before expiry to work, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_just_after_expiry", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, -1*time.Minute)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected expired token to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_expiration_edge_case", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, 0)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Token with zero expiration may be accepted (clock skew tolerance)")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenReplayAttack(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_replay_attack", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "replay", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
token := authClient.Token
|
||||
|
||||
t.Run("same_token_multiple_times", func(t *testing.T) {
|
||||
for i := 0; i < 5; i++ {
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected token to work multiple times (replay %d), got status %d", i+1, statusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_reuse_after_revocation", func(t *testing.T) {
|
||||
authClient.RevokeAllTokens(t)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Access token may still work after refresh token revocation (acceptable)")
|
||||
}
|
||||
|
||||
_, statusCode = authClient.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh token to be rejected after revocation, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_reuse_after_user_deletion", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "deleteuser", "Password123!")
|
||||
deleteClient := ctx.loginUser(t, testUser.Username, testUser.Password)
|
||||
deleteToken := deleteClient.Token
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
deleteClient.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
deleteClient.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, deleteToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token to be rejected after user deletion, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenScope(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_scope", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "scope", "Password123!")
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
t.Run("access_token_cannot_be_used_as_refresh", func(t *testing.T) {
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
accessToken := authClient.Token
|
||||
|
||||
refreshData := map[string]string{
|
||||
"refresh_token": accessToken,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(refreshData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal refresh data: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/refresh", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create refresh request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make refresh request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Errorf("Expected access token to be rejected as refresh token, got status 200")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_token_cannot_access_protected_endpoints", func(t *testing.T) {
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshTokenString := authClient.RefreshToken
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, refreshTokenString)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh token string to be rejected for protected endpoints, got status %d", statusCode)
|
||||
}
|
||||
|
||||
invalidTypeToken := generateTokenWithType(t, user, &cfg.JWT, "invalid-type")
|
||||
statusCode = ctx.makeRequestWithToken(t, invalidTypeToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected invalid token type to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_type_validation", func(t *testing.T) {
|
||||
emptyTypeToken := generateTokenWithType(t, user, &cfg.JWT, "")
|
||||
statusCode := ctx.makeRequestWithToken(t, emptyTypeToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected empty token type to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentLoginPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_login_prevention", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "concurrent", "Password123!")
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
initialVersion := user.SessionVersion
|
||||
|
||||
login1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
login2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
login3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after logins: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion != initialVersion {
|
||||
t.Log("Session version may increment on login (acceptable behavior)")
|
||||
}
|
||||
|
||||
profile1 := login1.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login1 to work")
|
||||
}
|
||||
|
||||
profile2 := login2.GetProfile(t)
|
||||
if profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login2 to work")
|
||||
}
|
||||
|
||||
profile3 := login3.GetProfile(t)
|
||||
if profile3.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login3 to work")
|
||||
}
|
||||
|
||||
if login1.Token == login2.Token || login1.Token == login3.Token || login2.Token == login3.Token {
|
||||
t.Errorf("Expected concurrent logins to generate different tokens")
|
||||
}
|
||||
})
|
||||
}
|
||||
874
internal/e2e/security_test.go
Normal file
874
internal/e2e/security_test.go
Normal file
@@ -0,0 +1,874 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SecurityWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("security_workflows", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "testuser", "StrongPass123!")
|
||||
_ = ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
t.Run("unauthorized_access_attempts", 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 401 for unauthorized access, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_token_access", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth("invalid-token-12345").
|
||||
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 401 for invalid token, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limiting", func(t *testing.T) {
|
||||
rateLimitCtx := setupTestContextWithAuthRateLimit(t, 5)
|
||||
rateLimitUser := rateLimitCtx.createUserWithCleanup(t, "ratelimituser", "StrongPass123!")
|
||||
_ = rateLimitCtx.loginUser(t, rateLimitUser.Username, rateLimitUser.Password)
|
||||
|
||||
testIP := testutils.GenerateTestIP()
|
||||
rateLimited := false
|
||||
for range 10 {
|
||||
statusCode := rateLimitCtx.loginExpectStatusWithIP(t, rateLimitUser.Username, "WrongPass123!", http.StatusUnauthorized, testIP)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
rateLimited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rateLimited {
|
||||
t.Errorf("Expected rate limiting to occur after multiple failed login attempts")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SearchSanitization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("search_sanitization", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "This post contains searchable content")
|
||||
|
||||
benignSearch := authClient.SearchPosts(t, "searchable")
|
||||
if !benignSearch.Success {
|
||||
t.Errorf("Expected benign search to succeed, got failure: %s", benignSearch.Message)
|
||||
}
|
||||
if len(benignSearch.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to find post with benign search query")
|
||||
}
|
||||
|
||||
maliciousQuery := "searchable'; DROP TABLE users; --"
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/posts/search?q="+url.QueryEscape(maliciousQuery)).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create malicious search request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make malicious search request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for malicious search query, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
sqlPayloads := testutils.SQLInjectionPayloads
|
||||
|
||||
t.Run("sql_injection_in_post_fields", func(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)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
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 crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test2-%d", i),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_registration_fields", func(t *testing.T) {
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
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 username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
regData2 := map[string]string{
|
||||
"username": uniqueUsername(t, fmt.Sprintf("user%d", i)),
|
||||
"email": fmt.Sprintf("test%s@example.com", payload),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in email caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_url_fields", func(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)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test%s", payload),
|
||||
"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 URL caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_query_parameters", func(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)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
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 crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusOK {
|
||||
t.Logf("SQL injection in search query returned status %d (acceptable if sanitized). Payload: %s", resp.StatusCode, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_XSSPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
xssPayloads := testutils.XSSPayloads
|
||||
|
||||
t.Run("xss_in_post_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
for idx, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", idx), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/xss-test-%d", idx),
|
||||
"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("XSS payload in title caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Title, "<script") {
|
||||
t.Errorf("XSS payload not sanitized in title response. Payload: %s, Response: %s", payload, postResp.Data.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", idx),
|
||||
"url": fmt.Sprintf("https://example.com/xss-test2-%d", idx),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp2.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Content, "<script") || strings.Contains(postResp.Data.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in content response. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_username_fields", func(t *testing.T) {
|
||||
for i, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
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("XSS payload in username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_search_queries", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest2", "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 xssPayloads {
|
||||
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("XSS payload in search query caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var searchResp PostsListResponse
|
||||
if err := json.NewDecoder(reader).Decode(&searchResp); err != nil {
|
||||
t.Fatalf("Failed to decode search response: %v", err)
|
||||
}
|
||||
|
||||
for _, post := range searchResp.Data.Posts {
|
||||
if strings.Contains(post.Title, "<script") || strings.Contains(post.Title, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post title. Payload: %s", payload)
|
||||
}
|
||||
if strings.Contains(post.Content, "<script") || strings.Contains(post.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post content. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_InformationDisclosure(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("information_disclosure", func(t *testing.T) {
|
||||
t.Run("error_messages_dont_reveal_sensitive_info", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/login").
|
||||
WithJSONBody(map[string]string{
|
||||
"username": "nonexistent",
|
||||
"password": "wrongpassword",
|
||||
}).
|
||||
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()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "database") {
|
||||
t.Errorf("Error message should not reveal database information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "sql") {
|
||||
t.Errorf("Error message should not reveal SQL information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "stack") {
|
||||
t.Errorf("Error message should not reveal stack trace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_endpoints_dont_reveal_structure", func(t *testing.T) {
|
||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/nonexistent/endpoint", nil)
|
||||
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()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "route") && resp.StatusCode == http.StatusNotFound {
|
||||
t.Logf("404 response may contain route information, which is acceptable")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
func TestE2E_SecurityHeadersEnhanced(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",
|
||||
}
|
||||
|
||||
t.Run("security_headers_values", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
}{
|
||||
{"health", "GET", "/health", false},
|
||||
{"metrics", "GET", "/metrics", false},
|
||||
{"api_posts", "GET", "/api/posts", true},
|
||||
{"api_auth_me", "GET", "/api/auth/me", true},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
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 && authClient != nil {
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if strings.Contains(csp, "unsafe-inline") && !strings.Contains(csp, "'nonce-") {
|
||||
t.Errorf("Endpoint %s: CSP contains unsafe-inline without nonce", endpoint.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hsts_header", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
hsts := resp.Header.Get("Strict-Transport-Security")
|
||||
if hsts == "" {
|
||||
t.Error("HSTS header should be present for HTTPS requests")
|
||||
}
|
||||
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Errorf("HSTS header should contain max-age, got: %s", hsts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
t.Run("verification_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "hashtest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
authClient.RegisterUser(t, "newuser", "newuser@example.com", "Password123!")
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Expected verification token to be generated")
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByUsername("newuser")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
if user.EmailVerificationToken == verificationToken {
|
||||
t.Error("Verification token should be hashed in database")
|
||||
}
|
||||
|
||||
if len(user.EmailVerificationToken) < 32 {
|
||||
t.Errorf("Hashed token should be at least 32 characters, got %d", len(user.EmailVerificationToken))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("password_reset_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "resettest", "StrongPass123!")
|
||||
ctx.server.EmailSender.Reset()
|
||||
|
||||
testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, testUser.Email, testutils.GenerateTestIP())
|
||||
|
||||
resetToken := ctx.server.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Skip("Rate limited, skipping token hashing test")
|
||||
return
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(resetToken))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(ctx.server.DB)
|
||||
_, err := deletionRepo.GetByTokenHash(tokenHash)
|
||||
if err == nil {
|
||||
t.Log("Password reset token appears to be stored hashed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SecurityHeaderCombinations(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("all_security_headers_present", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
requiredHeaders := []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"Content-Security-Policy",
|
||||
}
|
||||
|
||||
for _, header := range requiredHeaders {
|
||||
if resp.Header.Get(header) == "" {
|
||||
t.Errorf("Required security header missing: %s", header)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
125
internal/e2e/static_files_test.go
Normal file
125
internal/e2e/static_files_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_StaticFileServing(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("static_css_file_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/css/main.css", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/css") && !strings.Contains(contentType, "application/octet-stream") {
|
||||
t.Logf("Unexpected Content-Type for CSS file: %s", contentType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
t.Error("Static CSS file is empty")
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
t.Log("Static CSS file not found (may not exist in test environment)")
|
||||
} else {
|
||||
t.Errorf("Expected status 200 or 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_file_not_found", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/nonexistent/file.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404 for nonexistent file, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_directory_listing_disabled", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden {
|
||||
t.Logf("Directory listing status: %d (acceptable)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_favicon_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/favicon.ico", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "image") && !strings.Contains(contentType, "application/octet-stream") {
|
||||
t.Logf("Unexpected Content-Type for favicon: %s", contentType)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
t.Log("Favicon not found (may not exist in test environment)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_path_traversal_prevented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/../common.go", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 404 or 403 for path traversal attempt, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
179
internal/e2e/user_test.go
Normal file
179
internal/e2e/user_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_UserDirectory(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_directory", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 3, "user", "StrongPass123!")
|
||||
|
||||
authClient := ctx.loginUser(t, users[0].Username, users[0].Password)
|
||||
|
||||
usersResp := authClient.GetUsers(t)
|
||||
if len(usersResp.Data.Users) < 3 {
|
||||
t.Errorf("Expected at least 3 users, got %d", len(usersResp.Data.Users))
|
||||
}
|
||||
|
||||
for _, user := range usersResp.Data.Users {
|
||||
if user.Username == "" {
|
||||
t.Errorf("Expected username to be present, got empty string")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_management", func(t *testing.T) {
|
||||
createdUser, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
profile := authClient.GetProfile(t)
|
||||
assertUserResponse(t, profile, createdUser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileAccessAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_access_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "profileuser", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
user2CurrentUsername := user2.Username
|
||||
|
||||
t.Run("users_only_see_own_profile_via_me_endpoint", func(t *testing.T) {
|
||||
profile1 := authClient1.GetProfile(t)
|
||||
if profile1.Data.ID != user1.ID {
|
||||
t.Errorf("User1's /api/auth/me shows wrong ID: expected %d, got %d", user1.ID, profile1.Data.ID)
|
||||
}
|
||||
if profile1.Data.Username != user1.Username {
|
||||
t.Errorf("User1's /api/auth/me shows wrong username: expected '%s', got '%s'", user1.Username, profile1.Data.Username)
|
||||
}
|
||||
if profile1.Data.Email != user1.Email {
|
||||
t.Errorf("User1's /api/auth/me shows wrong email: expected '%s', got '%s'", user1.Email, profile1.Data.Email)
|
||||
}
|
||||
|
||||
profile2 := authClient2.GetProfile(t)
|
||||
if profile2.Data.ID != user2.ID {
|
||||
t.Errorf("User2's /api/auth/me shows wrong ID: expected %d, got %d", user2.ID, profile2.Data.ID)
|
||||
}
|
||||
if profile2.Data.Username != user2.Username {
|
||||
t.Errorf("User2's /api/auth/me shows wrong username: expected '%s', got '%s'", user2.Username, profile2.Data.Username)
|
||||
}
|
||||
if profile2.Data.Email != user2.Email {
|
||||
t.Errorf("User2's /api/auth/me shows wrong email: expected '%s', got '%s'", user2.Email, profile2.Data.Email)
|
||||
}
|
||||
|
||||
if profile1.Data.ID == profile2.Data.ID {
|
||||
t.Errorf("User1 and User2 profiles should have different IDs via /api/auth/me, but both show %d", profile1.Data.ID)
|
||||
}
|
||||
if profile1.Data.Username == profile2.Data.Username {
|
||||
t.Errorf("User1 and User2 profiles should have different usernames via /api/auth/me, but both show '%s'", profile1.Data.Username)
|
||||
}
|
||||
if profile1.Data.Email == profile2.Data.Email {
|
||||
t.Errorf("User1 and User2 profiles should have different emails via /api/auth/me, but both show '%s'", profile1.Data.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_email", func(t *testing.T) {
|
||||
originalProfile1 := authClient1.GetProfile(t)
|
||||
originalEmail1 := originalProfile1.Data.Email
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
statusCode := authClient2.UpdateEmailExpectStatus(t, uniqueEmail(t, "newemail2"))
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected User2 to be able to update their own email with status 200, got %d", statusCode)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken != "" {
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
}
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Email != originalEmail1 {
|
||||
t.Errorf("User2 updating their own email should not affect User1's email. Expected '%s', got '%s'", originalEmail1, updatedProfile1.Data.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_username", func(t *testing.T) {
|
||||
originalProfile1 := authClient1.GetProfile(t)
|
||||
originalUsername1 := originalProfile1.Data.Username
|
||||
|
||||
user2CurrentUsername = uniqueUsername(t, "newusername2")
|
||||
authClient2.UpdateUsername(t, user2CurrentUsername)
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Username != originalUsername1 {
|
||||
t.Errorf("User2 updating their own username should not affect User1's username. Expected '%s', got '%s'", originalUsername1, updatedProfile1.Data.Username)
|
||||
}
|
||||
|
||||
updatedProfile2 := authClient2.GetProfile(t)
|
||||
if updatedProfile2.Data.Username == originalUsername1 {
|
||||
t.Errorf("Expected User2's username to be updated, but it's still '%s'", originalUsername1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_password", func(t *testing.T) {
|
||||
baselineAuthClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
if baselineAuthClient1.Token == "" {
|
||||
t.Fatalf("User1 should be able to login with original password before User2's update")
|
||||
}
|
||||
|
||||
authClient2.UpdatePassword(t, "StrongPass123!", "NewPass456!")
|
||||
|
||||
newAuthClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
if newAuthClient1.Token == "" {
|
||||
t.Errorf("User1 should still be able to login with original password after User2 updates their own password")
|
||||
}
|
||||
|
||||
profile1After := newAuthClient1.GetProfile(t)
|
||||
if profile1After.Data.Username != user1.Username {
|
||||
t.Errorf("User1's username should remain unchanged after User2's password update. Expected '%s', got '%s'", user1.Username, profile1After.Data.Username)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_updates_dont_affect_user2", func(t *testing.T) {
|
||||
authClient2 = ctx.loginUser(t, user2CurrentUsername, "NewPass456!")
|
||||
originalProfile2 := authClient2.GetProfile(t)
|
||||
originalUsername2 := originalProfile2.Data.Username
|
||||
|
||||
authClient1.UpdateUsername(t, uniqueUsername(t, "newusername1"))
|
||||
|
||||
updatedProfile2 := authClient2.GetProfile(t)
|
||||
if updatedProfile2.Data.Username != originalUsername2 {
|
||||
t.Errorf("User1 updating their own username should not affect User2's username. Expected '%s', got '%s'", originalUsername2, updatedProfile2.Data.Username)
|
||||
}
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Username == originalUsername2 {
|
||||
t.Errorf("Expected User1's username to be updated, but it's still '%s'", originalUsername2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profiles_remain_isolated_after_updates", func(t *testing.T) {
|
||||
authClient2 = ctx.loginUser(t, user2CurrentUsername, "NewPass456!")
|
||||
finalProfile1 := authClient1.GetProfile(t)
|
||||
finalProfile2 := authClient2.GetProfile(t)
|
||||
|
||||
if finalProfile1.Data.ID == finalProfile2.Data.ID {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different IDs, but both show %d", finalProfile1.Data.ID)
|
||||
}
|
||||
if finalProfile1.Data.Username == finalProfile2.Data.Username {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different usernames, but both show '%s'", finalProfile1.Data.Username)
|
||||
}
|
||||
if finalProfile1.Data.Email == finalProfile2.Data.Email {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different emails, but both show '%s'", finalProfile1.Data.Email)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
192
internal/e2e/version_test.go
Normal file
192
internal/e2e/version_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_VersionEndpoint(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("version_in_api_info", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for /api endpoint, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := apiInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("API info data is not a map")
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok {
|
||||
t.Error("Version field missing or not a string in API info")
|
||||
return
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
t.Error("Version is empty")
|
||||
}
|
||||
|
||||
versionPattern := regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
||||
if !versionPattern.MatchString(version) {
|
||||
t.Errorf("Version format invalid, expected semantic version (x.y.z), got: %s", version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_in_health_endpoint", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for /health endpoint, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var healthInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&healthInfo); err != nil {
|
||||
t.Fatalf("Failed to decode health response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := healthInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Health data is not a map")
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok {
|
||||
t.Error("Version field missing or not a string in health info")
|
||||
return
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
t.Error("Version is empty")
|
||||
}
|
||||
|
||||
versionPattern := regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
||||
if !versionPattern.MatchString(version) {
|
||||
t.Errorf("Version format invalid, expected semantic version (x.y.z), got: %s", version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_consistency", func(t *testing.T) {
|
||||
apiReq, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(apiReq)
|
||||
|
||||
apiResp, err := ctx.client.Do(apiReq)
|
||||
if err != nil {
|
||||
t.Fatalf("API request failed: %v", err)
|
||||
}
|
||||
defer apiResp.Body.Close()
|
||||
|
||||
healthReq, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create health request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(healthReq)
|
||||
|
||||
healthResp, err := ctx.client.Do(healthReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Health request failed: %v", err)
|
||||
}
|
||||
defer healthResp.Body.Close()
|
||||
|
||||
if apiResp.StatusCode != http.StatusOK || healthResp.StatusCode != http.StatusOK {
|
||||
t.Skip("One or both endpoints unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(apiResp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info: %v", err)
|
||||
}
|
||||
|
||||
var healthInfo map[string]interface{}
|
||||
if err := json.NewDecoder(healthResp.Body).Decode(&healthInfo); err != nil {
|
||||
t.Fatalf("Failed to decode health info: %v", err)
|
||||
}
|
||||
|
||||
apiData, _ := apiInfo["data"].(map[string]interface{})
|
||||
healthData, _ := healthInfo["data"].(map[string]interface{})
|
||||
|
||||
apiVersion, apiOk := apiData["version"].(string)
|
||||
healthVersion, healthOk := healthData["version"].(string)
|
||||
|
||||
if apiOk && healthOk && apiVersion != healthVersion {
|
||||
t.Errorf("Version mismatch: /api has %s, /health has %s", apiVersion, healthVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_format_validation", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("API endpoint unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info: %v", err)
|
||||
}
|
||||
|
||||
data, ok := apiInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok || version == "" {
|
||||
return
|
||||
}
|
||||
|
||||
semverPattern := regexp.MustCompile(`^\d+\.\d+\.\d+(-[a-zA-Z0-9-]+)?(\+[a-zA-Z0-9-]+)?$`)
|
||||
if !semverPattern.MatchString(version) {
|
||||
t.Logf("Version '%s' does not strictly follow semantic versioning (acceptable)", version)
|
||||
}
|
||||
})
|
||||
}
|
||||
266
internal/e2e/votes_test.go
Normal file
266
internal/e2e/votes_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_VoteManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_operations", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content for voting")
|
||||
|
||||
var voteResp *VoteResponse
|
||||
statusCode := retryOnRateLimit(t, 3, func() int {
|
||||
resp, code := authClient.VoteOnPostRaw(t, createdPost.ID, "up")
|
||||
if code == http.StatusOK {
|
||||
voteResp = resp
|
||||
}
|
||||
return code
|
||||
})
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
t.Skip("Skipping vote operations test: rate limited after retries")
|
||||
return
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("Vote failed with status %d", statusCode)
|
||||
}
|
||||
assertVoteResponse(t, voteResp, "up")
|
||||
|
||||
userVote := authClient.GetUserVote(t, createdPost.ID)
|
||||
if !userVote.Success {
|
||||
t.Errorf("Expected to get user vote, got failure: %s", userVote.Message)
|
||||
}
|
||||
userVoteData := assertVoteData(t, userVote)
|
||||
if hasVote, ok := userVoteData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected has_vote true after casting vote, got %#v", userVoteData["has_vote"])
|
||||
}
|
||||
|
||||
postVotes := authClient.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
if count, ok := postVotesData["count"].(float64); !ok || count < 1 {
|
||||
t.Errorf("Expected post votes count to be >= 1, got %#v", postVotesData["count"])
|
||||
}
|
||||
|
||||
authClient.RemoveVote(t, createdPost.ID)
|
||||
|
||||
removedVote := authClient.GetUserVote(t, createdPost.ID)
|
||||
if !removedVote.Success {
|
||||
t.Errorf("Expected to get vote removal state, got failure: %s", removedVote.Message)
|
||||
}
|
||||
removedVoteData := assertVoteData(t, removedVote)
|
||||
if hasVote, ok := removedVoteData["has_vote"].(bool); ok && hasVote {
|
||||
t.Errorf("Expected has_vote false after removal, got true")
|
||||
}
|
||||
if voteVal, present := removedVoteData["vote"]; present && voteVal != nil {
|
||||
t.Errorf("Expected vote data to be nil after removal, got %#v", voteVal)
|
||||
}
|
||||
|
||||
postVotesAfter := authClient.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotesAfter.Success {
|
||||
t.Errorf("Expected to get post votes after removal, got failure: %s", postVotesAfter.Message)
|
||||
}
|
||||
postVotesAfterData := assertVoteData(t, postVotesAfter)
|
||||
if count, ok := postVotesAfterData["count"].(float64); ok && count != 0 {
|
||||
t.Errorf("Expected post votes count to be 0 after removal, got %v", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_VoteAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "voteuser", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
createdPost := authClient1.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content for voting tests")
|
||||
|
||||
t.Run("users_can_only_vote_with_own_token", func(t *testing.T) {
|
||||
voteResp1 := authClient1.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp1.Success {
|
||||
t.Errorf("Expected User1 to be able to vote with their own token, got failure: %s", voteResp1.Message)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1.Success {
|
||||
t.Errorf("Expected to get User1's vote, got failure: %s", userVote1.Message)
|
||||
}
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if hasVote, ok := userVote1Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1 to have a vote after voting, got has_vote=%v", userVote1Data["has_vote"])
|
||||
}
|
||||
|
||||
voteResp2 := authClient2.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp2.Success {
|
||||
t.Errorf("Expected User2 to be able to vote with their own token, got failure: %s", voteResp2.Message)
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
if !userVote2.Success {
|
||||
t.Errorf("Expected to get User2's vote, got failure: %s", userVote2.Message)
|
||||
}
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if hasVote, ok := userVote2Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2 to have a vote after voting, got has_vote=%v", userVote2Data["has_vote"])
|
||||
}
|
||||
|
||||
userVote1After := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1After.Success {
|
||||
t.Errorf("Expected to still get User1's vote after User2 votes, got failure: %s", userVote1After.Message)
|
||||
}
|
||||
userVote1AfterData := assertVoteData(t, userVote1After)
|
||||
if hasVote, ok := userVote1AfterData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1's vote to still exist after User2 votes, got has_vote=%v", userVote1AfterData["has_vote"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_counts_reflect_authenticated_votes", func(t *testing.T) {
|
||||
postVotes := authClient1.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
|
||||
count, ok := postVotesData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesData["count"])
|
||||
}
|
||||
if count < 2 {
|
||||
t.Errorf("Expected vote count to be at least 2 (User1 and User2 both voted), got %v", count)
|
||||
}
|
||||
|
||||
votesArray, ok := postVotesData["votes"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected votes to be an array, got %T", postVotesData["votes"])
|
||||
}
|
||||
if len(votesArray) < 2 {
|
||||
t.Errorf("Expected at least 2 votes in the votes array, got %d", len(votesArray))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_can_only_modify_own_votes", func(t *testing.T) {
|
||||
authClient1.RemoveVote(t, createdPost.ID)
|
||||
|
||||
userVote1After := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1After.Success {
|
||||
t.Errorf("Expected to get vote state after removal, got failure: %s", userVote1After.Message)
|
||||
}
|
||||
userVote1AfterData := assertVoteData(t, userVote1After)
|
||||
if hasVote, ok := userVote1AfterData["has_vote"].(bool); ok && hasVote {
|
||||
t.Errorf("Expected User1's vote to be removed, but has_vote is still true")
|
||||
}
|
||||
|
||||
userVote2After := authClient2.GetUserVote(t, createdPost.ID)
|
||||
if !userVote2After.Success {
|
||||
t.Errorf("Expected to get User2's vote, got failure: %s", userVote2After.Message)
|
||||
}
|
||||
userVote2AfterData := assertVoteData(t, userVote2After)
|
||||
if hasVote, ok := userVote2AfterData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2's vote to still exist after User1 removes their vote, got has_vote=%v", userVote2AfterData["has_vote"])
|
||||
}
|
||||
|
||||
postVotesAfter := authClient1.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotesAfter.Success {
|
||||
t.Errorf("Expected to get post votes after removal, got failure: %s", postVotesAfter.Message)
|
||||
}
|
||||
postVotesAfterData := assertVoteData(t, postVotesAfter)
|
||||
countAfter, ok := postVotesAfterData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesAfterData["count"])
|
||||
}
|
||||
|
||||
if countAfter < 1 {
|
||||
t.Errorf("Expected vote count to be at least 1 after User1 removes vote, got %v", countAfter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_counts_accurate_with_different_types", func(t *testing.T) {
|
||||
voteResp1Down := authClient1.VoteOnPost(t, createdPost.ID, "down")
|
||||
if !voteResp1Down.Success {
|
||||
t.Errorf("Expected User1 to be able to vote down, got failure: %s", voteResp1Down.Message)
|
||||
}
|
||||
|
||||
postVotes := authClient2.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
|
||||
count := postVotesData["count"].(float64)
|
||||
if count < 2 {
|
||||
t.Errorf("Expected vote count to be at least 2 (User1 downvote, User2 upvote), got %v", count)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if voteData, exists := userVote1Data["vote"].(map[string]any); exists {
|
||||
if voteType, exists := voteData["type"].(string); exists {
|
||||
if voteType != "down" {
|
||||
t.Errorf("Expected User1's vote type to be 'down', got '%s'", voteType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if voteData, exists := userVote2Data["vote"].(map[string]any); exists {
|
||||
if voteType, exists := voteData["type"].(string); exists {
|
||||
if voteType != "up" {
|
||||
t.Errorf("Expected User2's vote type to be 'up', got '%s'", voteType)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple_users_vote_independently", func(t *testing.T) {
|
||||
user3 := ctx.createUserWithCleanup(t, "voteuser3", "StrongPass123!")
|
||||
authClient3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
|
||||
voteResp3 := authClient3.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp3.Success {
|
||||
t.Errorf("Expected User3 to be able to vote, got failure: %s", voteResp3.Message)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if hasVote, ok := userVote1Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1 to still have a vote")
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if hasVote, ok := userVote2Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2 to still have a vote")
|
||||
}
|
||||
|
||||
userVote3 := authClient3.GetUserVote(t, createdPost.ID)
|
||||
userVote3Data := assertVoteData(t, userVote3)
|
||||
if hasVote, ok := userVote3Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User3 to have a vote after voting")
|
||||
}
|
||||
|
||||
postVotes := authClient3.GetPostVotes(t, createdPost.ID)
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
count, ok := postVotesData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesData["count"])
|
||||
}
|
||||
if count < 3 {
|
||||
t.Errorf("Expected vote count to be at least 3 (three users voted), got %v", count)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
611
internal/e2e/workflows_realistic_test.go
Normal file
611
internal/e2e/workflows_realistic_test.go
Normal file
@@ -0,0 +1,611 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func findPostInList(postsResp *testutils.PostsListResponse, postID uint) *testutils.Post {
|
||||
if postsResp == nil || postsResp.Data.Posts == nil {
|
||||
return nil
|
||||
}
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == postID {
|
||||
return &post
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestE2E_NewUserOnboarding(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("new_user_onboarding", func(t *testing.T) {
|
||||
username := uniqueUsername(t, "newuser")
|
||||
email := uniqueEmail(t, "newuser")
|
||||
password := "Password123!"
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
statusCode := ctx.registerUserExpectStatus(t, username, email, password)
|
||||
if statusCode != http.StatusCreated {
|
||||
t.Fatalf("Expected registration to succeed, got status %d", statusCode)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatalf("Expected verification token")
|
||||
}
|
||||
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
|
||||
authClient := ctx.loginUser(t, username, password)
|
||||
if authClient.Token == "" {
|
||||
t.Fatalf("Expected login to succeed after email verification")
|
||||
}
|
||||
|
||||
createdPost := authClient.CreatePost(t, "My First Post", "https://example.com/first", "This is my first post content")
|
||||
if createdPost.ID == 0 {
|
||||
t.Errorf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed, got failure: %s", voteResp.Message)
|
||||
}
|
||||
|
||||
profile := authClient.GetProfile(t)
|
||||
if profile.Data.Username != username {
|
||||
t.Errorf("Expected profile username to match, got '%s'", profile.Data.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ReturningUserSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("returning_user_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "returning", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
if postsResp == nil {
|
||||
t.Errorf("Expected posts response")
|
||||
}
|
||||
|
||||
post1 := authClient.CreatePost(t, "Post 1", "https://example.com/post1", "Content 1")
|
||||
post2 := authClient.CreatePost(t, "Post 2", "https://example.com/post2", "Content 2")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post1.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post2.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
postsResp = authClient.GetPosts(t)
|
||||
if postsResp == nil || len(postsResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to retrieve posts")
|
||||
}
|
||||
|
||||
authClient.Logout(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PowerUserWorkflow(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("power_user_workflow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "poweruser", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
var postIDs []uint
|
||||
for i := 1; i <= 5; i++ {
|
||||
post := authClient.CreatePost(t,
|
||||
uniqueTestID(t)+" Post "+fmt.Sprintf("%d", i),
|
||||
"https://example.com/power"+uniqueTestID(t)+fmt.Sprintf("%d", i),
|
||||
"Content "+fmt.Sprintf("%d", i))
|
||||
postIDs = append(postIDs, post.ID)
|
||||
}
|
||||
|
||||
for i, postID := range postIDs {
|
||||
voteType := "up"
|
||||
if i%2 == 0 {
|
||||
voteType = "down"
|
||||
}
|
||||
voteResp := authClient.VoteOnPost(t, postID, voteType)
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed on post %d", postID)
|
||||
}
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
firstPost := findPostInList(postsResp, postIDs[0])
|
||||
if firstPost == nil {
|
||||
t.Fatalf("Expected to retrieve first post")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, postIDs[0], "Updated Title", "https://example.com/updated", "Updated content")
|
||||
updatedPostsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(updatedPostsResp, postIDs[0])
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated Title" {
|
||||
t.Errorf("Expected post title to be updated, got '%s'", updatedPost.Title)
|
||||
}
|
||||
|
||||
authClient.DeletePost(t, postIDs[len(postIDs)-1])
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
deletedPost := findPostInList(finalPostsResp, postIDs[len(postIDs)-1])
|
||||
if deletedPost != nil {
|
||||
t.Errorf("Expected deleted post to not be accessible")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PasswordResetFlowRealistic(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("password_reset_flow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "resetflow", "Password123!")
|
||||
_ = ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Email, testutils.GenerateTestIP())
|
||||
|
||||
resetToken := ctx.server.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Fatalf("Expected password reset token")
|
||||
}
|
||||
|
||||
newPassword := "NewPassword456!"
|
||||
statusCode := testutils.ResetPassword(t, ctx.client, ctx.baseURL, resetToken, newPassword, testutils.GenerateTestIP())
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("Expected password reset to succeed, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldLoginStatus := ctx.loginExpectStatus(t, createdUser.Username, "Password123!", http.StatusUnauthorized)
|
||||
if oldLoginStatus == http.StatusOK {
|
||||
t.Log("Old password may still work briefly (acceptable)")
|
||||
}
|
||||
|
||||
newClient := ctx.loginUser(t, createdUser.Username, newPassword)
|
||||
if newClient.Token == "" {
|
||||
t.Errorf("Expected login with new password to succeed")
|
||||
}
|
||||
|
||||
newClient.UpdatePassword(t, newPassword, "AnotherPassword789!")
|
||||
finalClient := ctx.loginUser(t, createdUser.Username, "AnotherPassword789!")
|
||||
if finalClient.Token == "" {
|
||||
t.Errorf("Expected login with final password to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostLifecycle(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_lifecycle", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "lifecycle", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Original Title", "https://example.com/lifecycle", "Original content")
|
||||
if createdPost.ID == 0 {
|
||||
t.Fatalf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, createdPost.ID, "Updated Title", "https://example.com/lifecycle", "Updated content")
|
||||
postsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(postsResp, createdPost.ID)
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated Title" {
|
||||
t.Errorf("Expected post to be updated")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, createdPost.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote change to succeed")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, createdPost.ID, "Final Title", "https://example.com/lifecycle", "Final content")
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, createdPost.ID)
|
||||
if finalPost == nil {
|
||||
t.Fatalf("Expected to retrieve final post")
|
||||
}
|
||||
if finalPost.Title != "Final Title" {
|
||||
t.Errorf("Expected post to be updated again")
|
||||
}
|
||||
|
||||
authClient.DeletePost(t, createdPost.ID)
|
||||
deletedPostsResp := authClient.GetPosts(t)
|
||||
deletedPost := findPostInList(deletedPostsResp, createdPost.ID)
|
||||
if deletedPost != nil {
|
||||
t.Errorf("Expected deleted post to not be accessible")
|
||||
}
|
||||
|
||||
recreatedPost := authClient.CreatePost(t, "Recreated Title", "https://example.com/lifecycle-recreated", "Recreated content")
|
||||
if recreatedPost.ID == 0 {
|
||||
t.Errorf("Expected post recreation to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_VotePatterns(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_patterns", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "votepattern", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post := authClient.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected upvote to succeed")
|
||||
}
|
||||
|
||||
userVote := authClient.GetUserVote(t, post.ID)
|
||||
if userVote == nil || userVote.Data == nil {
|
||||
t.Errorf("Expected to retrieve user vote")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected downvote to succeed")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "none")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote removal to succeed")
|
||||
}
|
||||
|
||||
userVote = authClient.GetUserVote(t, post.ID)
|
||||
if userVote != nil && userVote.Data != nil {
|
||||
voteData, ok := userVote.Data.(map[string]any)
|
||||
if ok {
|
||||
if voteType, exists := voteData["type"]; exists && voteType != nil && voteType != "none" {
|
||||
t.Errorf("Expected vote to be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected upvote after removal to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileUpdateFlow(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_update_flow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "profile", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
_ = authClient.GetProfile(t)
|
||||
|
||||
newUsername := uniqueUsername(t, "updated")
|
||||
authClient.UpdateUsername(t, newUsername)
|
||||
updatedProfile := authClient.GetProfile(t)
|
||||
if updatedProfile.Data.Username != newUsername {
|
||||
t.Errorf("Expected username to be updated, got '%s'", updatedProfile.Data.Username)
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
newEmail := uniqueEmail(t, "updated")
|
||||
authClient.UpdateEmail(t, newEmail)
|
||||
emailProfile := authClient.GetProfile(t)
|
||||
normalizedNewEmail := strings.ToLower(strings.TrimSpace(newEmail))
|
||||
if emailProfile.Data.Email != normalizedNewEmail {
|
||||
t.Errorf("Expected email to be updated, got '%s'", emailProfile.Data.Email)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatalf("Expected verification token after email update")
|
||||
}
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword999!")
|
||||
passwordClient := ctx.loginUser(t, newUsername, "NewPassword999!")
|
||||
if passwordClient.Token == "" {
|
||||
t.Errorf("Expected login with new password to succeed")
|
||||
}
|
||||
|
||||
finalProfile := passwordClient.GetProfile(t)
|
||||
if finalProfile.Data.Username != newUsername {
|
||||
t.Errorf("Expected username to remain updated, got '%s'", finalProfile.Data.Username)
|
||||
}
|
||||
if finalProfile.Data.Email != normalizedNewEmail {
|
||||
t.Errorf("Expected email to remain updated, got '%s'", finalProfile.Data.Email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_MultiUserInteraction(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("multi_user_interaction", func(t *testing.T) {
|
||||
userA := ctx.createUserWithCleanup(t, "usera", "Password123!")
|
||||
userB := ctx.createUserWithCleanup(t, "userb", "Password123!")
|
||||
|
||||
clientA := ctx.loginUser(t, userA.Username, userA.Password)
|
||||
clientB := ctx.loginUser(t, userB.Username, userB.Password)
|
||||
|
||||
post := clientA.CreatePost(t, "User A's Post", "https://example.com/usera", "Content from User A")
|
||||
if post.ID == 0 {
|
||||
t.Fatalf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := clientB.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected User B to vote on User A's post")
|
||||
}
|
||||
|
||||
clientA.UpdatePost(t, post.ID, "Updated by User A", "https://example.com/usera", "Updated content")
|
||||
postsResp := clientB.GetPosts(t)
|
||||
updatedPost := findPostInList(postsResp, post.ID)
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated by User A" {
|
||||
t.Errorf("Expected User B to see updated post")
|
||||
}
|
||||
|
||||
voteResp = clientB.VoteOnPost(t, post.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected User B to change vote")
|
||||
}
|
||||
|
||||
finalPostsResp := clientA.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, post.ID)
|
||||
if finalPost == nil {
|
||||
t.Errorf("Expected User A to retrieve final post")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ContentDiscovery(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("content_discovery", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "discovery", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post1 := authClient.CreatePost(t, "Golang Tutorial", "https://example.com/golang", "Learn Go programming")
|
||||
post2 := authClient.CreatePost(t, "Python Guide", "https://example.com/python", "Python programming guide")
|
||||
post3 := authClient.CreatePost(t, "Rust Basics", "https://example.com/rust", "Rust programming basics")
|
||||
|
||||
authClient.VoteOnPost(t, post1.ID, "up")
|
||||
authClient.VoteOnPost(t, post2.ID, "up")
|
||||
authClient.VoteOnPost(t, post3.ID, "down")
|
||||
|
||||
searchResp := authClient.SearchPosts(t, "Golang")
|
||||
if searchResp == nil || len(searchResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected search to find posts")
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
if postsResp == nil || len(postsResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to retrieve posts")
|
||||
}
|
||||
|
||||
authClient.VoteOnPost(t, post1.ID, "up")
|
||||
updatedPostsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(updatedPostsResp, post1.ID)
|
||||
if updatedPost == nil {
|
||||
t.Errorf("Expected to retrieve updated post")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SessionPersistence(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_persistence", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "session", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
profile1 := authClient.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected first profile request to succeed")
|
||||
}
|
||||
|
||||
ctx.assertEventually(t, func() bool {
|
||||
profile2 := authClient.GetProfile(t)
|
||||
return profile2 != nil && profile2.Data.Username == createdUser.Username
|
||||
}, 2*time.Second)
|
||||
|
||||
profile2 := authClient.GetProfile(t)
|
||||
if profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected second profile request to succeed")
|
||||
}
|
||||
|
||||
postsResp1 := authClient.GetPosts(t)
|
||||
postsResp2 := authClient.GetPosts(t)
|
||||
|
||||
if postsResp1 == nil || postsResp2 == nil {
|
||||
t.Errorf("Expected multiple requests with same session to work")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentRequestsWithSameSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_requests_same_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "concurrent", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
results := make(chan bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
profile := authClient.GetProfile(t)
|
||||
results <- (profile != nil && profile.Data.Username == createdUser.Username)
|
||||
}()
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
if <-results {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
t.Errorf("Expected at least some concurrent requests to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_UserAgentHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_agent_headers", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "useragent", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
userAgents := []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
"Mozilla/5.0 (X11; Linux x86_64)",
|
||||
"Go-http-client/1.1",
|
||||
}
|
||||
|
||||
for _, ua := range userAgents {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth(authClient.Token).
|
||||
WithHeader("User-Agent", ua).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create request with User-Agent: %s", ua)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Errorf("Request failed with User-Agent %s: %v", ua, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 with User-Agent %s, got %d", ua, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RefererHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("referer_headers", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "referer", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
referers := []string{
|
||||
"https://example.com/page1",
|
||||
"https://example.com/page2",
|
||||
"http://localhost:3000",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, referer := range referers {
|
||||
builder := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth(authClient.Token)
|
||||
if referer != "" {
|
||||
builder = builder.WithHeader("Referer", referer)
|
||||
}
|
||||
request, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create request with Referer: %s", referer)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Errorf("Request failed with Referer %s: %v", referer, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 with Referer %s, got %d", referer, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RapidSuccessiveActions(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("rapid_successive_actions", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "rapid", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post := authClient.CreatePost(t, "Rapid Vote Test", "https://example.com/rapid", "Content")
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
voteType := "up"
|
||||
if i%2 == 0 {
|
||||
voteType = "down"
|
||||
}
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, voteType)
|
||||
if !voteResp.Success {
|
||||
t.Logf("Vote %d may have been rate limited (acceptable)", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, post.ID)
|
||||
if finalPost == nil {
|
||||
t.Errorf("Expected to retrieve post after rapid votes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LongRunningSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("long_running_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "longsession", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
profile1 := authClient.GetProfile(t)
|
||||
if profile1 == nil {
|
||||
t.Fatalf("Expected initial profile request to succeed")
|
||||
}
|
||||
|
||||
post := authClient.CreatePost(t, "Long Session Post", "https://example.com/long", "Content")
|
||||
if post.ID == 0 {
|
||||
t.Errorf("Expected post creation after delay to succeed")
|
||||
}
|
||||
|
||||
profile2 := authClient.GetProfile(t)
|
||||
if profile2 == nil || profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected profile request after delay to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote after delay to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
246
internal/e2e/workflows_test.go
Normal file
246
internal/e2e/workflows_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_CompleteUserJourney(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("complete_user_journey", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Test Post", "https://example.com/test", "This is a test post content")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to be successful, got failure: %s", voteResp.Message)
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
assertPostInList(t, postsResp, createdPost)
|
||||
|
||||
searchResp := authClient.SearchPosts(t, "test")
|
||||
assertPostInList(t, searchResp, createdPost)
|
||||
|
||||
authClient.Logout(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ErrorHandlingWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("unauthenticated_user_workflow", func(t *testing.T) {
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader([]byte(`{"title":"Test","url":"https://example.com"}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
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.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for unauthenticated post creation, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
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 401 for unauthenticated profile access, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_registration_workflow", func(t *testing.T) {
|
||||
invalidData := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "empty_username",
|
||||
body: []byte(`{"username":"","email":"test@example.com","password":"ValidPass123!"}`),
|
||||
},
|
||||
{
|
||||
name: "invalid_email",
|
||||
body: []byte(`{"username":"testuser","email":"invalid-email","password":"ValidPass123!"}`),
|
||||
},
|
||||
{
|
||||
name: "weak_password",
|
||||
body: []byte(`{"username":"testuser","email":"test@example.com","password":"123"}`),
|
||||
},
|
||||
{
|
||||
name: "malformed_json",
|
||||
body: []byte(`{"username": "test", "password": }`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range invalidData {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithBody(bytes.NewReader(test.body)).
|
||||
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.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected invalid registration to fail, got success status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentUserWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_user_workflows", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 3, "concurrent", "StrongPass123!")
|
||||
|
||||
type result struct {
|
||||
userID uint
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, len(users))
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
|
||||
for _, user := range users {
|
||||
u := user
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
authClient, loginErr := ctx.loginUserSafe(t, u.Username, u.Password)
|
||||
if loginErr != nil || authClient == nil || authClient.Token == "" {
|
||||
err = fmt.Errorf("User %s failed to login", u.Username)
|
||||
} else {
|
||||
postURL := fmt.Sprintf("https://example.com/concurrent/%d", u.ID)
|
||||
post, postErr := authClient.CreatePostSafe("Concurrent Post", postURL, "Content")
|
||||
if postErr != nil || post == nil || post.ID == 0 {
|
||||
err = fmt.Errorf("User %s failed to create post: %v", u.Username, postErr)
|
||||
} else {
|
||||
voteResp, voteErr := authClient.VoteOnPostSafe(post.ID, "up")
|
||||
if voteErr != nil || voteResp == nil || !voteResp.Success {
|
||||
err = fmt.Errorf("User %s failed to vote: %v", u.Username, voteErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case results <- result{userID: u.ID, err: err}:
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
timeout := time.After(10 * time.Second)
|
||||
successCount := 0
|
||||
receivedCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case res, ok := <-results:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
receivedCount++
|
||||
if res.err != nil {
|
||||
t.Errorf("Concurrent operation error for user %d: %v", res.userID, res.err)
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
if receivedCount >= len(users) {
|
||||
return
|
||||
}
|
||||
case <-timeout:
|
||||
close(done)
|
||||
t.Errorf("Timeout waiting for concurrent operations to complete")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SystemMonitoringWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("system_monitoring_workflows", func(t *testing.T) {
|
||||
t.Run("health_endpoint", func(t *testing.T) {
|
||||
health := getHealth(t, ctx.client, ctx.baseURL)
|
||||
if !health.Success {
|
||||
t.Errorf("Expected health check to succeed, got failure: %s", health.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metrics_endpoint", func(t *testing.T) {
|
||||
metrics := getMetrics(t, ctx.client, ctx.baseURL)
|
||||
if metrics == nil {
|
||||
t.Errorf("Expected metrics to be returned")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_AccountDeletion(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("account_deletion_flow", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
_ = authClient.CreatePost(t, "Test Post", "https://example.com/test", "Test content")
|
||||
|
||||
statusCode, deletionResp := ctx.requestAccountDeletionExpectStatus(t, authClient.Token, http.StatusOK)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
statusCode = retryOnRateLimit(t, 3, func() int {
|
||||
code, _ := ctx.requestAccountDeletionExpectStatus(t, authClient.Token, http.StatusOK)
|
||||
return code
|
||||
})
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
t.Skip("Skipping account deletion flow test: rate limited after retries")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if deletionResp == nil {
|
||||
t.Fatalf("Expected account deletion response, got nil")
|
||||
}
|
||||
if !deletionResp.Success {
|
||||
t.Errorf("Expected account deletion request to be successful, got %v", deletionResp.Success)
|
||||
}
|
||||
if deletionResp.Message == "" {
|
||||
t.Errorf("Expected deletion message to be present, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user