Files
honeyDueAPI/internal/task/consistency_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

661 lines
20 KiB
Go

// Package task provides consistency tests that verify all three layers
// (predicates, scopes, and categorization) return identical results.
//
// These tests are critical for ensuring the DRY architecture is maintained.
// If any of these tests fail, it means the three layers have diverged and
// will produce inconsistent results in different parts of the application.
package task_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/categorization"
"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
// testUserID is a user ID that exists in the database for foreign key constraints
var testUserID uint = 1
// 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) {
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("Consistency integration tests will be skipped: database not available")
println("Set TEST_DATABASE_URL to run these tests")
os.Exit(m.Run())
}
sqlDB, err := testDB.DB()
if err != nil {
println("Failed to get underlying DB:", err.Error())
testDB = nil
os.Exit(m.Run())
}
if pingErr := sqlDB.Ping(); pingErr != nil {
println("Failed to ping database:", pingErr.Error())
testDB = nil
os.Exit(m.Run())
}
println("Database connected, running consistency tests...")
code := m.Run()
cleanupTestData()
os.Exit(code)
}
func cleanupTestData() {
if testDB == nil {
return
}
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'consistency_test_%')")
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'consistency_test_%'")
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'consistency_test_%'")
}
func timePtr(t time.Time) *time.Time {
return &t
}
func createResidence(t *testing.T) uint {
residence := &models.Residence{
Name: "consistency_test_" + time.Now().Format("20060102150405.000"),
OwnerID: testUserID,
IsActive: true,
}
if err := testDB.Create(residence).Error; err != nil {
t.Fatalf("Failed to create residence: %v", err)
}
return residence.ID
}
func createTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
task.ResidenceID = residenceID
task.Title = "consistency_test_" + task.Title
task.CreatedByID = testUserID
if err := testDB.Create(task).Error; err != nil {
t.Fatalf("Failed to create task: %v", err)
}
return task
}
func createCompletion(t *testing.T, taskID uint) {
completion := &models.TaskCompletion{
TaskID: taskID,
CompletedByID: testUserID,
CompletedAt: time.Now().UTC(),
}
if err := testDB.Create(completion).Error; err != nil {
t.Fatalf("Failed to create completion: %v", err)
}
}
// TaskTestCase defines a test scenario with expected categorization
type TaskTestCase struct {
Name string
Task *models.Task
HasCompletion bool
ExpectedColumn categorization.KanbanColumn
// Expected predicate results
ExpectCompleted bool
ExpectActive bool
ExpectOverdue bool
ExpectDueSoon bool
ExpectUpcoming bool
ExpectInProgress bool
}
// TestAllThreeLayersMatch is the master consistency test.
// It creates tasks in the database, then verifies that:
// 1. Predicates return the expected boolean values
// 2. Categorization returns the expected kanban column
// 3. Scopes return the same tasks that predicates would filter
func TestAllThreeLayersMatch(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createResidence(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
// Define all test cases with expected results for each layer
testCases := []TaskTestCase{
{
Name: "overdue_active",
Task: &models.Task{
Title: "overdue_active",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnOverdue,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true,
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "due_soon_active",
Task: &models.Task{
Title: "due_soon_active",
NextDueDate: timePtr(in5Days),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnDueSoon,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: true,
ExpectUpcoming: false,
},
{
Name: "upcoming_far_future",
Task: &models.Task{
Title: "upcoming_far_future",
NextDueDate: timePtr(in60Days),
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnUpcoming,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: false,
ExpectUpcoming: true,
},
{
Name: "upcoming_no_due_date",
Task: &models.Task{
Title: "upcoming_no_due_date",
NextDueDate: nil,
DueDate: nil,
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnUpcoming,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: false,
ExpectUpcoming: true,
},
{
Name: "completed_one_time",
Task: &models.Task{
Title: "completed_one_time",
NextDueDate: nil, // No next due date
DueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: false,
},
HasCompletion: true, // Will create a completion
ExpectedColumn: categorization.ColumnCompleted,
ExpectCompleted: true,
ExpectActive: true,
ExpectOverdue: false, // Completed tasks are not overdue
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "cancelled_task",
Task: &models.Task{
Title: "cancelled_task",
NextDueDate: timePtr(yesterday), // Would be overdue if not cancelled
IsCancelled: true,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnCancelled,
ExpectCompleted: false,
ExpectActive: false, // Cancelled is not active
ExpectOverdue: false, // Cancelled tasks are not overdue
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "archived_task",
Task: &models.Task{
Title: "archived_task",
NextDueDate: timePtr(yesterday),
IsCancelled: false,
IsArchived: true,
},
// Archived tasks don't appear in kanban (filtered out before categorization)
// but we test predicates still work
ExpectedColumn: categorization.ColumnOverdue, // Chain doesn't check archived
ExpectCompleted: false,
ExpectActive: false, // Archived is not active
ExpectOverdue: false, // Archived tasks are not overdue (IsOverdue checks IsActive)
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "recurring_with_completion",
Task: &models.Task{
Title: "recurring_with_completion",
NextDueDate: timePtr(in5Days), // Has next due date (recurring)
IsCancelled: false,
IsArchived: false,
},
HasCompletion: true,
ExpectedColumn: categorization.ColumnDueSoon, // Not completed because NextDueDate is set
ExpectCompleted: false, // Has completion but NextDueDate is set
ExpectActive: true,
ExpectOverdue: false,
ExpectDueSoon: true,
ExpectUpcoming: false,
},
{
Name: "overdue_uses_duedate_fallback",
Task: &models.Task{
Title: "overdue_uses_duedate_fallback",
NextDueDate: nil,
DueDate: timePtr(yesterday), // Falls back to DueDate
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnOverdue,
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true,
ExpectDueSoon: false,
ExpectUpcoming: false,
},
{
Name: "in_progress_overdue",
Task: &models.Task{
Title: "in_progress_overdue",
NextDueDate: timePtr(yesterday), // Would be overdue
InProgress: true,
IsCancelled: false,
IsArchived: false,
},
ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority
ExpectCompleted: false,
ExpectActive: true,
ExpectOverdue: true, // Predicate says overdue (doesn't check InProgress)
ExpectDueSoon: false,
ExpectUpcoming: false,
ExpectInProgress: true,
},
}
// Create all tasks in database
createdTasks := make(map[string]*models.Task)
for _, tc := range testCases {
task := createTask(t, residenceID, tc.Task)
if tc.HasCompletion {
createCompletion(t, task.ID)
}
createdTasks[tc.Name] = task
}
// Reload all tasks with preloads for predicate testing
var allTasks []models.Task
err := testDB.
Preload("Completions").
Where("residence_id = ?", residenceID).
Find(&allTasks).Error
if err != nil {
t.Fatalf("Failed to load tasks: %v", err)
}
// Create a map for easy lookup
taskMap := make(map[string]*models.Task)
for i := range allTasks {
// Strip the prefix for lookup
name := allTasks[i].Title[len("consistency_test_"):]
taskMap[name] = &allTasks[i]
}
// Test each case
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
task := taskMap[tc.Name]
if task == nil {
t.Fatalf("Task %s not found", tc.Name)
}
// ========== TEST PREDICATES ==========
t.Run("predicates", func(t *testing.T) {
if got := predicates.IsCompleted(task); got != tc.ExpectCompleted {
t.Errorf("IsCompleted() = %v, want %v", got, tc.ExpectCompleted)
}
if got := predicates.IsActive(task); got != tc.ExpectActive {
t.Errorf("IsActive() = %v, want %v", got, tc.ExpectActive)
}
if got := predicates.IsOverdue(task, now); got != tc.ExpectOverdue {
t.Errorf("IsOverdue() = %v, want %v", got, tc.ExpectOverdue)
}
if got := predicates.IsDueSoon(task, now, daysThreshold); got != tc.ExpectDueSoon {
t.Errorf("IsDueSoon() = %v, want %v", got, tc.ExpectDueSoon)
}
if got := predicates.IsUpcoming(task, now, daysThreshold); got != tc.ExpectUpcoming {
t.Errorf("IsUpcoming() = %v, want %v", got, tc.ExpectUpcoming)
}
if tc.ExpectInProgress {
if got := predicates.IsInProgress(task); got != tc.ExpectInProgress {
t.Errorf("IsInProgress() = %v, want %v", got, tc.ExpectInProgress)
}
}
})
// ========== TEST CATEGORIZATION ==========
t.Run("categorization", func(t *testing.T) {
got := categorization.CategorizeTask(task, daysThreshold)
if got != tc.ExpectedColumn {
t.Errorf("CategorizeTask() = %v, want %v", got, tc.ExpectedColumn)
}
})
})
}
// ========== TEST SCOPES MATCH PREDICATES ==========
// This is the critical test: query with scopes and verify results match predicate filtering
t.Run("scopes_match_predicates", func(t *testing.T) {
// Test ScopeActive
t.Run("ScopeActive", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeActive).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsActive(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeActive returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeCompleted
t.Run("ScopeCompleted", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsCompleted(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeCompleted returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeOverdue
t.Run("ScopeOverdue", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsOverdue(&task, now) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeOverdue returned %d, predicates found %d", len(scopeResults), predicateCount)
t.Logf("Scope results: %v", getTaskNames(scopeResults))
t.Logf("Predicate matches: %v", getPredicateMatches(allTasks, func(task *models.Task) bool {
return predicates.IsOverdue(task, now)
}))
}
})
// Test ScopeDueSoon
t.Run("ScopeDueSoon", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsDueSoon(&task, now, daysThreshold) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeDueSoon returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeUpcoming
t.Run("ScopeUpcoming", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeUpcoming(now, daysThreshold)).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsUpcoming(&task, now, daysThreshold) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeUpcoming returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
// Test ScopeInProgress
t.Run("ScopeInProgress", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
Find(&scopeResults)
predicateCount := 0
for _, task := range allTasks {
if predicates.IsInProgress(&task) {
predicateCount++
}
}
if len(scopeResults) != predicateCount {
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
}
})
})
// ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ==========
// Verify that tasks categorized into each column match what scopes would return
t.Run("categorization_matches_scopes", func(t *testing.T) {
// Get categorization results
categorized := categorization.CategorizeTasksIntoColumns(allTasks, daysThreshold)
// Compare overdue column with scope
// NOTE: Scopes return tasks based on date criteria only.
// Categorization uses priority order (In Progress > Overdue).
// So a task that is overdue by date but "In Progress" won't be in ColumnOverdue.
// We need to compare scope results MINUS those with higher-priority categorization.
t.Run("overdue_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
// Filter scope results to exclude tasks that would be categorized differently
// (i.e., tasks that are In Progress - higher priority than Overdue)
scopeOverdueNotInProgress := 0
for _, task := range scopeResults {
if !predicates.IsInProgress(&task) {
scopeOverdueNotInProgress++
}
}
// Count active overdue tasks in categorization
activeOverdue := 0
for _, task := range categorized[categorization.ColumnOverdue] {
if predicates.IsActive(&task) {
activeOverdue++
}
}
if scopeOverdueNotInProgress != activeOverdue {
t.Errorf("Overdue: scope returned %d (excluding in-progress), categorization has %d active",
scopeOverdueNotInProgress, activeOverdue)
}
})
// Compare due soon column with scope
t.Run("due_soon_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeDueSoon(now, daysThreshold)).
Find(&scopeResults)
activeDueSoon := 0
for _, task := range categorized[categorization.ColumnDueSoon] {
if predicates.IsActive(&task) {
activeDueSoon++
}
}
if len(scopeResults) != activeDueSoon {
t.Errorf("DueSoon: scope returned %d, categorization has %d active",
len(scopeResults), activeDueSoon)
}
})
// Compare completed column with scope
t.Run("completed_column", func(t *testing.T) {
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeCompleted).
Find(&scopeResults)
if len(scopeResults) != len(categorized[categorization.ColumnCompleted]) {
t.Errorf("Completed: scope returned %d, categorization has %d",
len(scopeResults), len(categorized[categorization.ColumnCompleted]))
}
})
})
}
// TestSameDayOverdueConsistency is a regression test for day-based overdue logic.
// With day-based comparisons, a task due TODAY (at any time) is NOT overdue during that day.
// It only becomes overdue the NEXT day. This test verifies all three layers agree.
func TestSameDayOverdueConsistency(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
}
residenceID := createResidence(t)
defer cleanupTestData()
// Create a task due at midnight today
now := time.Now().UTC()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
task := createTask(t, residenceID, &models.Task{
Title: "same_day_midnight",
NextDueDate: timePtr(todayMidnight),
IsCancelled: false,
IsArchived: false,
})
// Reload with preloads
var loadedTask models.Task
testDB.Preload("Completions").First(&loadedTask, task.ID)
// All three layers should agree
predicateResult := predicates.IsOverdue(&loadedTask, now)
var scopeResults []models.Task
testDB.Model(&models.Task{}).
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
Find(&scopeResults)
scopeResult := len(scopeResults) == 1
categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue
// With day-based comparison: task due TODAY is NOT overdue during that day.
// All three layers should say NOT overdue.
if predicateResult {
t.Error("Predicate incorrectly says overdue for same-day task")
}
if scopeResult {
t.Error("Scope incorrectly says overdue for same-day task")
}
if categorizationResult {
t.Error("Categorization incorrectly says overdue for same-day task")
}
// Most importantly: all three must agree
if predicateResult != scopeResult {
t.Errorf("INCONSISTENCY: predicate=%v, scope=%v", predicateResult, scopeResult)
}
if predicateResult != categorizationResult {
t.Errorf("INCONSISTENCY: predicate=%v, categorization=%v", predicateResult, categorizationResult)
}
}
// Helper functions
func getTaskNames(tasks []models.Task) []string {
names := make([]string, len(tasks))
for i, t := range tasks {
names[i] = t.Title
}
return names
}
func getPredicateMatches(tasks []models.Task, predicate func(*models.Task) bool) []string {
var names []string
for _, t := range tasks {
if predicate(&t) {
names = append(names, t.Title)
}
}
return names
}