Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
633
internal/integration/security_regression_test.go
Normal file
633
internal/integration/security_regression_test.go
Normal file
@@ -0,0 +1,633 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user