- 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>
634 lines
24 KiB
Go
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(), ¬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")
|
|
}
|