package integration import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/admin/dto" adminhandlers "github.com/treytartt/casera-api/internal/admin/handlers" "github.com/treytartt/casera-api/internal/apperrors" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/handlers" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" "github.com/treytartt/casera-api/internal/testutil" "github.com/treytartt/casera-api/internal/validator" ) // ============ Security Regression Test App ============ // SecurityTestApp holds components for security regression integration testing. type SecurityTestApp struct { DB *gorm.DB Router *echo.Echo SubscriptionService *services.SubscriptionService SubscriptionRepo *repositories.SubscriptionRepository } func setupSecurityTest(t *testing.T) *SecurityTestApp { db := testutil.SetupTestDB(t) testutil.SeedLookupData(t, db) // Create repositories userRepo := repositories.NewUserRepository(db) residenceRepo := repositories.NewResidenceRepository(db) taskRepo := repositories.NewTaskRepository(db) contractorRepo := repositories.NewContractorRepository(db) documentRepo := repositories.NewDocumentRepository(db) subscriptionRepo := repositories.NewSubscriptionRepository(db) notificationRepo := repositories.NewNotificationRepository(db) // Create config cfg := &config.Config{ Security: config.SecurityConfig{ SecretKey: "test-secret-key-for-security-tests", PasswordResetExpiry: 15 * time.Minute, ConfirmationExpiry: 24 * time.Hour, MaxPasswordResetRate: 3, }, } // Create services authService := services.NewAuthService(userRepo, cfg) residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) taskService := services.NewTaskService(taskRepo, residenceRepo) notificationService := services.NewNotificationService(notificationRepo, nil) // Wire up subscription service for tier limit enforcement residenceService.SetSubscriptionService(subscriptionService) // Create handlers authHandler := handlers.NewAuthHandler(authService, nil, nil) residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true) taskHandler := handlers.NewTaskHandler(taskService, nil) contractorHandler := handlers.NewContractorHandler(services.NewContractorService(contractorRepo, residenceRepo)) notificationHandler := handlers.NewNotificationHandler(notificationService) subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService) // Create router with real middleware e := echo.New() e.Validator = validator.NewCustomValidator() e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.Use(middleware.TimezoneMiddleware()) // Public routes auth := e.Group("/api/auth") { auth.POST("/register", authHandler.Register) auth.POST("/login", authHandler.Login) } // Protected routes authMiddleware := middleware.NewAuthMiddleware(db, nil) api := e.Group("/api") api.Use(authMiddleware.TokenAuth()) { api.GET("/auth/me", authHandler.CurrentUser) api.POST("/auth/logout", authHandler.Logout) residences := api.Group("/residences") { residences.GET("", residenceHandler.ListResidences) residences.POST("", residenceHandler.CreateResidence) residences.GET("/:id", residenceHandler.GetResidence) residences.PUT("/:id", residenceHandler.UpdateResidence) residences.DELETE("/:id", residenceHandler.DeleteResidence) } tasks := api.Group("/tasks") { tasks.GET("", taskHandler.ListTasks) tasks.POST("", taskHandler.CreateTask) tasks.GET("/:id", taskHandler.GetTask) tasks.PUT("/:id", taskHandler.UpdateTask) tasks.DELETE("/:id", taskHandler.DeleteTask) } completions := api.Group("/completions") { completions.GET("", taskHandler.ListCompletions) completions.POST("", taskHandler.CreateCompletion) completions.GET("/:id", taskHandler.GetCompletion) completions.DELETE("/:id", taskHandler.DeleteCompletion) } contractors := api.Group("/contractors") { contractors.GET("", contractorHandler.ListContractors) contractors.POST("", contractorHandler.CreateContractor) contractors.GET("/:id", contractorHandler.GetContractor) } subscription := api.Group("/subscription") { subscription.GET("/", subscriptionHandler.GetSubscription) subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus) subscription.POST("/purchase/", subscriptionHandler.ProcessPurchase) } notifications := api.Group("/notifications") { notifications.GET("", notificationHandler.ListNotifications) } } return &SecurityTestApp{ DB: db, Router: e, SubscriptionService: subscriptionService, SubscriptionRepo: subscriptionRepo, } } // registerAndLoginSec registers and logs in a user, returns token and user ID. func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, password string) (string, uint) { // Register registerBody := map[string]string{ "username": username, "email": email, "password": password, } w := app.makeAuthReq(t, "POST", "/api/auth/register", registerBody, "") require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed for %s", username) // Login loginBody := map[string]string{ "username": username, "password": password, } w = app.makeAuthReq(t, "POST", "/api/auth/login", loginBody, "") require.Equal(t, http.StatusOK, w.Code, "Login should succeed for %s", username) var loginResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &loginResp) require.NoError(t, err) token := loginResp["token"].(string) userMap := loginResp["user"].(map[string]interface{}) userID := uint(userMap["id"].(float64)) return token, userID } // makeAuthReq creates and sends an HTTP request through the router. func (app *SecurityTestApp) makeAuthReq(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder { var reqBody []byte var err error if body != nil { reqBody, err = json.Marshal(body) require.NoError(t, err) } req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Token "+token) } w := httptest.NewRecorder() app.Router.ServeHTTP(w, req) return w } // ============ Test 1: Path Traversal Blocked ============ // TestE2E_PathTraversal_AllMediaEndpoints_Blocked verifies that the SafeResolvePath // function (used by all media endpoints) blocks path traversal attempts. // A document with a traversal URL like ../../../etc/passwd cannot be used to read // arbitrary files from the filesystem. func TestE2E_PathTraversal_AllMediaEndpoints_Blocked(t *testing.T) { // Test the SafeResolvePath function that guards all three media endpoints: // ServeDocument, ServeDocumentImage, ServeCompletionImage // Each calls resolveFilePath -> SafeResolvePath to validate containment. traversalPaths := []struct { name string url string }{ {"simple dotdot", "../../../etc/passwd"}, {"nested dotdot", "../../etc/shadow"}, {"embedded dotdot", "images/../../../../../../etc/passwd"}, {"deep traversal", "a/b/c/../../../../etc/passwd"}, {"uploads prefix with dotdot", "../../../etc/passwd"}, } for _, tt := range traversalPaths { t.Run(tt.name, func(t *testing.T) { // SafeResolvePath must reject all traversal attempts _, err := services.SafeResolvePath("/var/uploads", tt.url) assert.Error(t, err, "Path traversal should be blocked for: %s", tt.url) }) } // Verify that a legitimate path still works t.Run("legitimate_path_allowed", func(t *testing.T) { result, err := services.SafeResolvePath("/var/uploads", "documents/file.pdf") assert.NoError(t, err, "Legitimate path should be allowed") assert.Equal(t, "/var/uploads/documents/file.pdf", result) }) // Verify absolute paths are blocked t.Run("absolute_path_blocked", func(t *testing.T) { _, err := services.SafeResolvePath("/var/uploads", "/etc/passwd") assert.Error(t, err, "Absolute paths should be blocked") }) // Verify empty paths are blocked t.Run("empty_path_blocked", func(t *testing.T) { _, err := services.SafeResolvePath("/var/uploads", "") assert.Error(t, err, "Empty paths should be blocked") }) } // ============ Test 2: SQL Injection in Admin Sort ============ // TestE2E_SQLInjection_AdminSort_Blocked verifies that the admin user list endpoint // uses the allowlist-based sort column sanitization and does not execute injected SQL. func TestE2E_SQLInjection_AdminSort_Blocked(t *testing.T) { db := testutil.SetupTestDB(t) // Create admin user handler which uses the sort_by parameter adminUserHandler := adminhandlers.NewAdminUserHandler(db) // Create a couple of test users to have data to sort testutil.CreateTestUser(t, db, "alice", "alice@test.com", "password123") testutil.CreateTestUser(t, db, "bob", "bob@test.com", "password123") // Set up a minimal Echo instance with the admin handler e := echo.New() e.Validator = validator.NewCustomValidator() e.HTTPErrorHandler = apperrors.HTTPErrorHandler e.GET("/api/admin/users", adminUserHandler.List) injections := []struct { name string sortBy string }{ {"DROP TABLE", "created_at; DROP TABLE auth_user; --"}, {"UNION SELECT", "id UNION SELECT password FROM auth_user"}, {"subquery", "(SELECT password FROM auth_user LIMIT 1)"}, {"OR 1=1", "created_at OR 1=1"}, {"semicolon", "created_at;"}, {"single quotes", "name'; DROP TABLE auth_user; --"}, } for _, tt := range injections { t.Run(tt.name, func(t *testing.T) { path := fmt.Sprintf("/api/admin/users?sort_by=%s", tt.sortBy) w := testutil.MakeRequest(e, "GET", path, nil, "") // Handler should return 200 (using safe default sort), NOT 500 assert.Equal(t, http.StatusOK, w.Code, "Admin user list should succeed with safe default sort, not crash from injection: %s", tt.sortBy) // Parse response to verify valid paginated data var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(t, err, "Response should be valid JSON") // Verify the auth_user table still exists (not dropped) var count int64 dbErr := db.Model(&models.User{}).Count(&count).Error assert.NoError(t, dbErr, "auth_user table should still exist after injection attempt") assert.GreaterOrEqual(t, count, int64(2), "Users should still be in the database") }) } // Verify the DTO allowlist directly t.Run("DTO_GetSafeSortBy_rejects_injection", func(t *testing.T) { p := dto.PaginationParams{SortBy: "created_at; DROP TABLE auth_user; --"} result := p.GetSafeSortBy([]string{"id", "username", "email", "date_joined"}, "date_joined") assert.Equal(t, "date_joined", result, "Injection should fall back to default column") }) } // ============ Test 3: IAP Invalid Receipt Does Not Grant Pro ============ // TestE2E_IAP_InvalidReceipt_NoPro verifies that submitting a purchase with // garbage receipt data does NOT upgrade the user to Pro tier. func TestE2E_IAP_InvalidReceipt_NoPro(t *testing.T) { app := setupSecurityTest(t) token, userID := app.registerAndLoginSec(t, "iapuser", "iap@test.com", "password123") // Create initial subscription (free tier) sub := &models.UserSubscription{UserID: userID, Tier: models.TierFree} require.NoError(t, app.DB.Create(sub).Error) // Submit a purchase with garbage receipt data purchaseBody := map[string]interface{}{ "platform": "ios", "receipt_data": "GARBAGE_RECEIPT_DATA_THAT_IS_NOT_VALID", } w := app.makeAuthReq(t, "POST", "/api/subscription/purchase/", purchaseBody, token) // The purchase should fail (Apple client is nil in test environment) assert.NotEqual(t, http.StatusOK, w.Code, "Purchase with garbage receipt should NOT succeed") // Verify user is still on free tier updatedSub, err := app.SubscriptionRepo.GetOrCreate(userID) require.NoError(t, err) assert.Equal(t, models.TierFree, updatedSub.Tier, "User should remain on free tier after invalid receipt submission") } // ============ Test 4: Completion Transaction Atomicity ============ // TestE2E_CompletionTransaction_Atomic verifies that creating a task completion // updates both the completion record and the task's NextDueDate together (P1-5/P1-6). func TestE2E_CompletionTransaction_Atomic(t *testing.T) { app := setupSecurityTest(t) token, _ := app.registerAndLoginSec(t, "atomicuser", "atomic@test.com", "password123") // Create a residence residenceBody := map[string]interface{}{"name": "Atomic Test House"} w := app.makeAuthReq(t, "POST", "/api/residences", residenceBody, token) require.Equal(t, http.StatusCreated, w.Code) var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) residenceData := residenceResp["data"].(map[string]interface{}) residenceID := residenceData["id"].(float64) // Create a one-time task with a due date dueDate := time.Now().Add(7 * 24 * time.Hour).Format("2006-01-02") taskBody := map[string]interface{}{ "residence_id": uint(residenceID), "title": "One-Time Atomic Task", "due_date": dueDate, } w = app.makeAuthReq(t, "POST", "/api/tasks", taskBody, token) require.Equal(t, http.StatusCreated, w.Code) var taskResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskResp) taskData := taskResp["data"].(map[string]interface{}) taskID := taskData["id"].(float64) // Verify task has a next_due_date before completion w = app.makeAuthReq(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) require.Equal(t, http.StatusOK, w.Code) var taskBefore map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskBefore) assert.NotNil(t, taskBefore["next_due_date"], "Task should have next_due_date before completion") // Create completion completionBody := map[string]interface{}{ "task_id": uint(taskID), "notes": "Completed for atomicity test", } w = app.makeAuthReq(t, "POST", "/api/completions", completionBody, token) require.Equal(t, http.StatusCreated, w.Code) var completionResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &completionResp) completionData := completionResp["data"].(map[string]interface{}) completionID := completionData["id"].(float64) assert.NotZero(t, completionID, "Completion should be created with valid ID") // Verify task is now completed (next_due_date should be nil for one-time task) w = app.makeAuthReq(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) require.Equal(t, http.StatusOK, w.Code) var taskAfter map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskAfter) assert.Nil(t, taskAfter["next_due_date"], "One-time task should have nil next_due_date after completion (atomic update)") assert.Equal(t, "completed_tasks", taskAfter["kanban_column"], "Task should be in completed column after completion") // Verify completion record exists w = app.makeAuthReq(t, "GET", "/api/completions/"+formatID(completionID), nil, token) assert.Equal(t, http.StatusOK, w.Code, "Completion record should exist") } // ============ Test 5: Delete Completion Recalculates NextDueDate ============ // TestE2E_DeleteCompletion_RecalculatesNextDueDate verifies that deleting a completion // on a recurring task recalculates NextDueDate back to the correct value (P1-7). func TestE2E_DeleteCompletion_RecalculatesNextDueDate(t *testing.T) { app := setupSecurityTest(t) token, _ := app.registerAndLoginSec(t, "recuruser", "recur@test.com", "password123") // Create a residence residenceBody := map[string]interface{}{"name": "Recurring Test House"} w := app.makeAuthReq(t, "POST", "/api/residences", residenceBody, token) require.Equal(t, http.StatusCreated, w.Code) var residenceResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &residenceResp) residenceData := residenceResp["data"].(map[string]interface{}) residenceID := residenceData["id"].(float64) // Get the "Weekly" frequency ID from the database var weeklyFreq models.TaskFrequency err := app.DB.Where("name = ?", "Weekly").First(&weeklyFreq).Error require.NoError(t, err, "Weekly frequency should exist from seed data") // Create a recurring (weekly) task with a due date dueDate := time.Now().Add(-1 * 24 * time.Hour).Format("2006-01-02") taskBody := map[string]interface{}{ "residence_id": uint(residenceID), "title": "Weekly Recurring Task", "frequency_id": weeklyFreq.ID, "due_date": dueDate, } w = app.makeAuthReq(t, "POST", "/api/tasks", taskBody, token) require.Equal(t, http.StatusCreated, w.Code) var taskResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskResp) taskData := taskResp["data"].(map[string]interface{}) taskID := taskData["id"].(float64) // Record original next_due_date w = app.makeAuthReq(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) require.Equal(t, http.StatusOK, w.Code) var taskOriginal map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskOriginal) originalNextDueDate := taskOriginal["next_due_date"] require.NotNil(t, originalNextDueDate, "Recurring task should have initial next_due_date") // Create a completion (should advance NextDueDate by 7 days from completion date) completionBody := map[string]interface{}{ "task_id": uint(taskID), "notes": "Weekly completion", } w = app.makeAuthReq(t, "POST", "/api/completions", completionBody, token) require.Equal(t, http.StatusCreated, w.Code) var completionResp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &completionResp) completionData := completionResp["data"].(map[string]interface{}) completionID := completionData["id"].(float64) // Verify NextDueDate advanced w = app.makeAuthReq(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) require.Equal(t, http.StatusOK, w.Code) var taskAfterCompletion map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskAfterCompletion) advancedNextDueDate := taskAfterCompletion["next_due_date"] assert.NotNil(t, advancedNextDueDate, "Recurring task should still have next_due_date after completion") assert.NotEqual(t, originalNextDueDate, advancedNextDueDate, "NextDueDate should have advanced after completion") // Delete the completion w = app.makeAuthReq(t, "DELETE", "/api/completions/"+formatID(completionID), nil, token) require.Equal(t, http.StatusOK, w.Code) // Verify NextDueDate was recalculated back to original due date w = app.makeAuthReq(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) require.Equal(t, http.StatusOK, w.Code) var taskAfterDelete map[string]interface{} json.Unmarshal(w.Body.Bytes(), &taskAfterDelete) restoredNextDueDate := taskAfterDelete["next_due_date"] // After deleting the only completion, NextDueDate should be restored to the original DueDate assert.NotNil(t, restoredNextDueDate, "NextDueDate should be restored after deleting the only completion") assert.Equal(t, originalNextDueDate, restoredNextDueDate, "NextDueDate should be recalculated back to original due date after completion deletion") } // ============ Test 6: Tier Limits Enforced ============ // TestE2E_TierLimits_Enforced verifies that a free-tier user cannot exceed the // configured property limit. func TestE2E_TierLimits_Enforced(t *testing.T) { app := setupSecurityTest(t) token, userID := app.registerAndLoginSec(t, "tieruser", "tier@test.com", "password123") // Enable global limitations app.DB.Where("1=1").Delete(&models.SubscriptionSettings{}) settings := &models.SubscriptionSettings{EnableLimitations: true} require.NoError(t, app.DB.Create(settings).Error) // Set free tier limit to 1 property one := 1 app.DB.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{}) freeLimits := &models.TierLimits{ Tier: models.TierFree, PropertiesLimit: &one, } require.NoError(t, app.DB.Create(freeLimits).Error) // Ensure user is on free tier sub, err := app.SubscriptionRepo.GetOrCreate(userID) require.NoError(t, err) require.Equal(t, models.TierFree, sub.Tier) // First residence should succeed residenceBody := map[string]interface{}{"name": "First Property"} w := app.makeAuthReq(t, "POST", "/api/residences", residenceBody, token) require.Equal(t, http.StatusCreated, w.Code, "First residence should be allowed within limit") // Second residence should be blocked residenceBody2 := map[string]interface{}{"name": "Second Property (over limit)"} w = app.makeAuthReq(t, "POST", "/api/residences", residenceBody2, token) assert.Equal(t, http.StatusForbidden, w.Code, "Second residence should be blocked by tier limit") // Verify error response var errResp map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &errResp) require.NoError(t, err) assert.Contains(t, fmt.Sprintf("%v", errResp), "limit", "Error response should reference the limit") } // ============ Test 7: Auth Assertion -- No Panics on Missing User ============ // TestE2E_AuthAssertion_NoPanics verifies that all protected endpoints return // 401 Unauthorized (not 500 panic) when no auth token is provided. func TestE2E_AuthAssertion_NoPanics(t *testing.T) { app := setupSecurityTest(t) // Make requests to protected endpoints WITHOUT any token. endpoints := []struct { name string method string path string }{ {"ListTasks", "GET", "/api/tasks"}, {"CreateTask", "POST", "/api/tasks"}, {"GetTask", "GET", "/api/tasks/1"}, {"ListResidences", "GET", "/api/residences"}, {"CreateResidence", "POST", "/api/residences"}, {"GetResidence", "GET", "/api/residences/1"}, {"ListCompletions", "GET", "/api/completions"}, {"CreateCompletion", "POST", "/api/completions"}, {"ListContractors", "GET", "/api/contractors"}, {"CreateContractor", "POST", "/api/contractors"}, {"GetSubscription", "GET", "/api/subscription/"}, {"SubscriptionStatus", "GET", "/api/subscription/status/"}, {"ProcessPurchase", "POST", "/api/subscription/purchase/"}, {"ListNotifications", "GET", "/api/notifications"}, {"CurrentUser", "GET", "/api/auth/me"}, } for _, ep := range endpoints { t.Run(ep.name, func(t *testing.T) { w := app.makeAuthReq(t, ep.method, ep.path, nil, "") assert.Equal(t, http.StatusUnauthorized, w.Code, "Endpoint %s %s should return 401, not panic with 500", ep.method, ep.path) }) } // Also test with an invalid token (should be 401, not 500) t.Run("InvalidToken", func(t *testing.T) { w := app.makeAuthReq(t, "GET", "/api/tasks", nil, "completely-invalid-token-xyz") assert.Equal(t, http.StatusUnauthorized, w.Code, "Invalid token should return 401, not panic") }) } // ============ Test 8: Notification Limit Capped ============ // TestE2E_NotificationLimit_Capped verifies that the notification list endpoint // caps the limit parameter to 200 even if the client requests more. func TestE2E_NotificationLimit_Capped(t *testing.T) { app := setupSecurityTest(t) token, userID := app.registerAndLoginSec(t, "notifuser", "notif@test.com", "password123") // Create 210 notifications directly in the database for i := 0; i < 210; i++ { notification := &models.Notification{ UserID: userID, NotificationType: models.NotificationTaskCompleted, Title: fmt.Sprintf("Test Notification %d", i), Body: fmt.Sprintf("Body for notification %d", i), } require.NoError(t, app.DB.Create(notification).Error) } // Request with limit=999 (should be capped to 200 by the handler) w := app.makeAuthReq(t, "GET", "/api/notifications?limit=999", nil, token) require.Equal(t, http.StatusOK, w.Code) var notifResp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), ¬ifResp) require.NoError(t, err) count := int(notifResp["count"].(float64)) assert.LessOrEqual(t, count, 200, "Notification count should be capped at 200 even when requesting limit=999") results := notifResp["results"].([]interface{}) assert.LessOrEqual(t, len(results), 200, "Notification results should have at most 200 items") }