Files
honeyDueAPI/internal/task/scopes/scopes_test.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

680 lines
19 KiB
Go

package scopes_test
import (
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/task/predicates"
"github.com/treytartt/honeydue-api/internal/task/scopes"
)
// testDB holds the database connection for integration tests
var testDB *gorm.DB
// TestMain sets up the database connection for all tests in this package.
// If the database is not available, testDB remains nil and individual tests
// will call t.Skip() instead of using os.Exit(0), which preserves proper
// test reporting and coverage output.
func TestMain(m *testing.M) {
// Get database URL from environment or use default
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=honeydue_test port=5432 sslmode=disable"
}
var err error
testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
// Explicitly nil out testDB; individual tests will t.Skip("Database not available")
testDB = nil
println("Scope integration tests will be skipped: database not available")
println("Set TEST_DATABASE_URL to run these tests")
println("Error:", err.Error())
os.Exit(m.Run())
}
// Verify connection works
sqlDB, err := testDB.DB()
if err != nil {
println("Failed to get underlying DB:", err.Error())
testDB = nil
os.Exit(m.Run())
}
if err := sqlDB.Ping(); err != nil {
println("Failed to ping database:", err.Error())
testDB = nil
os.Exit(m.Run())
}
println("Database connected successfully, running integration tests...")
// Run migrations for test tables
err = testDB.AutoMigrate(
&models.Task{},
&models.TaskCompletion{},
&models.Residence{},
)
if err != nil {
println("Failed to run migrations:", err.Error())
testDB = nil
os.Exit(m.Run())
}
// Run tests
code := m.Run()
// Cleanup
cleanupTestData()
os.Exit(code)
}
// cleanupTestData removes all test data
func cleanupTestData() {
if testDB == nil {
return
}
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')")
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'")
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'")
}
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
// testUserID is a user ID that exists in the database for foreign key constraints
var testUserID uint = 1
// createTestResidence creates a test residence and returns its ID
func createTestResidence(t *testing.T) uint {
residence := &models.Residence{
Name: "test_residence_" + time.Now().Format("20060102150405"),
OwnerID: testUserID,
IsActive: true,
}
if err := testDB.Create(residence).Error; err != nil {
t.Fatalf("Failed to create test residence: %v", err)
}
return residence.ID
}
// createTestTask creates a task with the given properties
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
task.ResidenceID = residenceID
task.Title = "test_" + task.Title
task.CreatedByID = testUserID // Required foreign key
if err := testDB.Create(task).Error; err != nil {
t.Fatalf("Failed to create test task: %v", err)
}
return task
}
// createTestCompletion creates a completion for a task
func createTestCompletion(t *testing.T, taskID uint) *models.TaskCompletion {
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: testUserID, // Required foreign key
CompletedAt: time.Now().UTC(),
}
if err := testDB.Create(completion).Error; err != nil {
t.Fatalf("Failed to create test completion: %v", err)
}
return completion
}
// TestScopeActiveMatchesPredicate verifies ScopeActive produces same results as IsActive
func TestScopeActiveMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create tasks with different active states
tasks := []*models.Task{
{Title: "active_task", IsCancelled: false, IsArchived: false},
{Title: "cancelled_task", IsCancelled: true, IsArchived: false},
{Title: "archived_task", IsCancelled: false, IsArchived: true},
{Title: "both_task", IsCancelled: true, IsArchived: true},
}
for _, task := range tasks {
createTestTask(t, residenceID, task)
}
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsActive(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeActive returned %d tasks, IsActive predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the active task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 active task, got %d", len(scopeResults))
}
}
// TestScopeCompletedMatchesPredicate verifies ScopeCompleted produces same results as IsCompleted
func TestScopeCompletedMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
nextWeek := now.AddDate(0, 0, 7)
// Create tasks with different completion states
// Completed: NextDueDate nil AND has completions
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_task",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not completed: has completions but NextDueDate set (recurring)
recurringTask := createTestTask(t, residenceID, &models.Task{
Title: "recurring_with_completion",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, recurringTask.ID)
// Not completed: NextDueDate nil but no completions
createTestTask(t, residenceID, &models.Task{
Title: "no_completions",
NextDueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not completed: has NextDueDate, no completions
createTestTask(t, residenceID, &models.Task{
Title: "pending_task",
NextDueDate: timePtr(nextWeek),
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsCompleted(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeCompleted returned %d tasks, IsCompleted predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should only have the completed task (nil NextDueDate + has completion)
if len(scopeResults) != 1 {
t.Errorf("Expected 1 completed task, got %d", len(scopeResults))
}
}
// TestScopeOverdueMatchesPredicate verifies ScopeOverdue produces same results as IsOverdue
func TestScopeOverdueMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
// Overdue: NextDueDate in past, active, not completed
createTestTask(t, residenceID, &models.Task{
Title: "overdue_task",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Overdue: DueDate in past (NextDueDate nil, no completions)
createTestTask(t, residenceID, &models.Task{
Title: "overdue_duedate",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: future date
createTestTask(t, residenceID, &models.Task{
Title: "future_task",
NextDueDate: timePtr(tomorrow),
IsCancelled: false,
IsArchived: false,
})
// Not overdue: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled_overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: true,
IsArchived: false,
})
// Not overdue: completed (NextDueDate nil with completion)
completedTask := createTestTask(t, residenceID, &models.Task{
Title: "completed_past_due",
NextDueDate: nil,
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
createTestCompletion(t, completedTask.ID)
// Not overdue: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks with completions preloaded and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeOverdue returned %d tasks, IsOverdue predicate returned %d tasks",
len(scopeResults), len(predicateResults))
t.Logf("Scope results: %v", getTaskTitles(scopeResults))
t.Logf("Predicate results: %v", getTaskTitles(predicateResults))
}
// Should have 2 overdue tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 overdue tasks, got %d", len(scopeResults))
}
}
// TestScopeOverdueWithSameDayTask tests day-based overdue comparison.
// With day-based logic, a task due TODAY is NOT overdue during that same day.
// It only becomes overdue the NEXT day. Both scope and predicate should agree.
func TestScopeOverdueWithSameDayTask(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// Create a task due at midnight today (simulating a DATE column)
now := time.Now().UTC()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
createTestTask(t, residenceID, &models.Task{
Title: "due_today_midnight",
NextDueDate: timePtr(todayMidnight),
IsCancelled: false,
IsArchived: false,
})
// Query using scope with current time (after midnight)
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateResults = append(predicateResults, task)
}
}
// Both should agree: with day-based comparison, task due today is NOT overdue
if len(scopeResults) != len(predicateResults) {
t.Errorf("Scope/predicate mismatch! Scope returned %d, predicate returned %d",
len(scopeResults), len(predicateResults))
}
// With day-based comparison, task due today should NOT be overdue (it's due soon)
if len(scopeResults) != 0 {
t.Errorf("Task due today should NOT be overdue, got %d results (expected 0)", len(scopeResults))
}
}
// TestScopeDueSoonMatchesPredicate verifies ScopeDueSoon produces same results as IsDueSoon
func TestScopeDueSoonMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Due soon: within threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: overdue (in past)
createTestTask(t, residenceID, &models.Task{
Title: "overdue",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
})
// Not due soon: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in5Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsDueSoon(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeDueSoon returned %d tasks, IsDueSoon predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 due soon task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 due soon task, got %d", len(scopeResults))
}
}
// TestScopeUpcomingMatchesPredicate verifies ScopeUpcoming produces same results as IsUpcoming
func TestScopeUpcomingMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
now := time.Now().UTC()
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
// Upcoming: beyond threshold
createTestTask(t, residenceID, &models.Task{
Title: "far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
})
// Upcoming: no due date
createTestTask(t, residenceID, &models.Task{
Title: "no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: within due soon threshold
createTestTask(t, residenceID, &models.Task{
Title: "due_soon",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
})
// Not upcoming: cancelled
createTestTask(t, residenceID, &models.Task{
Title: "cancelled",
NextDueDate: timePtr(in60Days),
IsCancelled: true,
IsArchived: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Preload("Completions").Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsUpcoming(&task, now, daysThreshold) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeUpcoming returned %d tasks, IsUpcoming predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 2 upcoming tasks
if len(scopeResults) != 2 {
t.Errorf("Expected 2 upcoming tasks, got %d", len(scopeResults))
}
}
// TestScopeInProgressMatchesPredicate verifies ScopeInProgress produces same results as IsInProgress
func TestScopeInProgressMatchesPredicate(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createTestResidence(t)
defer cleanupTestData()
// In progress task
createTestTask(t, residenceID, &models.Task{
Title: "in_progress",
InProgress: true,
})
// Not in progress: InProgress is false
createTestTask(t, residenceID, &models.Task{
Title: "not_in_progress",
InProgress: false,
})
// Query using scope
var scopeResults []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
Find(&scopeResults).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
// Query all tasks and filter with predicate
var allTasks []models.Task
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
var predicateResults []models.Task
for _, task := range allTasks {
if predicates.IsInProgress(&task) {
predicateResults = append(predicateResults, task)
}
}
// Compare results
if len(scopeResults) != len(predicateResults) {
t.Errorf("ScopeInProgress returned %d tasks, IsInProgress predicate returned %d tasks",
len(scopeResults), len(predicateResults))
}
// Should have 1 in progress task
if len(scopeResults) != 1 {
t.Errorf("Expected 1 in progress task, got %d", len(scopeResults))
}
}
// TestScopeForResidences verifies filtering by multiple residence IDs
func TestScopeForResidences(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID1 := createTestResidence(t)
residenceID2 := createTestResidence(t)
residenceID3 := createTestResidence(t)
defer cleanupTestData()
// Create tasks in different residences
createTestTask(t, residenceID1, &models.Task{Title: "task_r1"})
createTestTask(t, residenceID2, &models.Task{Title: "task_r2"})
createTestTask(t, residenceID3, &models.Task{Title: "task_r3"})
// Query for residences 1 and 2 only
var results []models.Task
err := testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{residenceID1, residenceID2})).
Find(&results).Error
if err != nil {
t.Fatalf("Scope query failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 tasks from residences 1 and 2, got %d", len(results))
}
// Verify empty slice returns no results
var emptyResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidences([]uint{})).
Find(&emptyResults)
if len(emptyResults) != 0 {
t.Errorf("Expected 0 tasks for empty residence list, got %d", len(emptyResults))
}
}
// Helper to get task titles for debugging
func getTaskTitles(tasks []models.Task) []string {
titles := make([]string, len(tasks))
for i, task := range tasks {
titles[i] = task.Title
}
return titles
}