Files
honeyDueAPI/internal/integration/security_regression_test.go
Trey T bec880886b Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:30:09 -05:00

634 lines
24 KiB
Go

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/honeydue-api/internal/admin/dto"
adminhandlers "github.com/treytartt/honeydue-api/internal/admin/handlers"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
"github.com/treytartt/honeydue-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, nil)
// 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(), &notifResp)
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")
}