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:
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/dto/requests"
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
@@ -442,6 +443,333 @@ func TestTaskService_DeleteCompletion(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskService_CreateCompletion_TransactionIntegrity(t *testing.T) {
|
||||
// Verifies P1-5 / P1-6: completion creation and task update are atomic.
|
||||
// After completion, both the completion record AND the task's NextDueDate update
|
||||
// should succeed together, and errors should be propagated (not swallowed).
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
// Create a one-time task with a due date
|
||||
dueDate := time.Now().AddDate(0, 0, 7).UTC()
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "One-time Task",
|
||||
DueDate: &dueDate,
|
||||
NextDueDate: &dueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Version: 1,
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
req := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "Done",
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
resp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.Data.ID)
|
||||
|
||||
// Verify the task was updated: NextDueDate should be nil for a one-time task
|
||||
var reloaded models.Task
|
||||
db.First(&reloaded, task.ID)
|
||||
assert.Nil(t, reloaded.NextDueDate, "One-time task NextDueDate should be nil after completion")
|
||||
assert.False(t, reloaded.InProgress, "InProgress should be false after completion")
|
||||
|
||||
// Verify completion record exists
|
||||
var completion models.TaskCompletion
|
||||
err = db.Where("task_id = ?", task.ID).First(&completion).Error
|
||||
require.NoError(t, err, "Completion record should exist")
|
||||
assert.Equal(t, "Done", completion.Notes)
|
||||
}
|
||||
|
||||
func TestTaskService_CreateCompletion_UpdateError_ReturnedNotSwallowed(t *testing.T) {
|
||||
// Verifies P1-5 and P1-6: the completion creation and task update are wrapped
|
||||
// in a transaction, and update errors are returned (not swallowed).
|
||||
//
|
||||
// Strategy: We trigger a version conflict by using a goroutine that bumps
|
||||
// the task version after the service reads the task but during the transaction.
|
||||
// Since SQLite serializes writes, we instead verify the behavior by deleting
|
||||
// the task between the service read and the transactional update. When UpdateTx
|
||||
// tries to match the row by id+version, 0 rows are affected and ErrVersionConflict
|
||||
// is returned. The transaction then rolls back the completion insert.
|
||||
//
|
||||
// However, because the entire CreateCompletion flow is synchronous and we cannot
|
||||
// inject failures between steps, we instead verify the transactional guarantee
|
||||
// indirectly: we confirm that a concurrent version bump (set before the call
|
||||
// but after the SELECT) causes the version conflict to propagate. Since FindByID
|
||||
// re-reads the current version, we must verify via a custom test that invokes
|
||||
// the transaction layer directly.
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
dueDate := time.Now().AddDate(0, 0, 7).UTC()
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "Conflict Task",
|
||||
DueDate: &dueDate,
|
||||
NextDueDate: &dueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Version: 1,
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Directly test that the transactional path returns an error on version conflict:
|
||||
// Use a stale task object (version=1) when the DB has been bumped to version=999.
|
||||
db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 999)
|
||||
|
||||
completion := &models.TaskCompletion{
|
||||
TaskID: task.ID,
|
||||
CompletedByID: user.ID,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
Notes: "Should be rolled back",
|
||||
}
|
||||
|
||||
// Simulate the transaction that CreateCompletion now uses (task still has version=1)
|
||||
txErr := taskRepo.DB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := taskRepo.CreateCompletionTx(tx, completion); err != nil {
|
||||
return err
|
||||
}
|
||||
// task.Version is 1 but DB has 999 -> version conflict
|
||||
if err := taskRepo.UpdateTx(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
require.Error(t, txErr, "Transaction should fail due to version conflict")
|
||||
assert.ErrorIs(t, txErr, repositories.ErrVersionConflict, "Error should be ErrVersionConflict")
|
||||
|
||||
// Verify the completion was rolled back
|
||||
var count int64
|
||||
db.Model(&models.TaskCompletion{}).Where("task_id = ?", task.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count, "Completion should not exist when transaction rolls back")
|
||||
|
||||
// Also verify that CreateCompletion (full service method) would propagate the error.
|
||||
// Re-create the task with a normal version so FindByID works, then bump it.
|
||||
db.Model(&models.Task{}).Where("id = ?", task.ID).Update("version", 1)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
req := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "Test error propagation",
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
// This call will succeed because FindByID loads version=1, UpdateTx uses version=1, DB has version=1.
|
||||
// To verify error propagation, we use the direct transaction test above.
|
||||
resp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err, "CreateCompletion should succeed with matching versions")
|
||||
assert.NotZero(t, resp.Data.ID)
|
||||
}
|
||||
|
||||
func TestTaskService_DeleteCompletion_OneTime_RestoresOriginalDueDate(t *testing.T) {
|
||||
// Verifies P1-7: deleting the only completion on a one-time task
|
||||
// should restore NextDueDate to the original DueDate.
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
// Create a one-time task with a due date
|
||||
originalDueDate := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "One-time Task",
|
||||
DueDate: &originalDueDate,
|
||||
NextDueDate: &originalDueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Version: 1,
|
||||
// No FrequencyID = one-time task
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete the task (sets NextDueDate to nil for one-time tasks)
|
||||
req := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "Completed",
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
completionResp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Confirm NextDueDate is nil after completion
|
||||
var taskAfterComplete models.Task
|
||||
db.First(&taskAfterComplete, task.ID)
|
||||
assert.Nil(t, taskAfterComplete.NextDueDate, "NextDueDate should be nil after one-time completion")
|
||||
|
||||
// Delete the completion
|
||||
_, err = service.DeleteCompletion(completionResp.Data.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify NextDueDate is restored to the original DueDate
|
||||
var taskAfterDelete models.Task
|
||||
db.First(&taskAfterDelete, task.ID)
|
||||
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be restored after deleting completion")
|
||||
assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year())
|
||||
assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month())
|
||||
assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day())
|
||||
}
|
||||
|
||||
func TestTaskService_DeleteCompletion_Recurring_RecalculatesFromLastCompletion(t *testing.T) {
|
||||
// Verifies P1-7: deleting the latest completion on a recurring task
|
||||
// should recalculate NextDueDate from the remaining latest completion.
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
var monthlyFrequency models.TaskFrequency
|
||||
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
|
||||
|
||||
// Create a recurring task
|
||||
originalDueDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "Recurring Task",
|
||||
FrequencyID: &monthlyFrequency.ID,
|
||||
DueDate: &originalDueDate,
|
||||
NextDueDate: &originalDueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Version: 1,
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// First completion on Jan 15
|
||||
firstCompletedAt := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
firstReq := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "First completion",
|
||||
CompletedAt: &firstCompletedAt,
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = service.CreateCompletion(firstReq, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second completion on Feb 15
|
||||
secondCompletedAt := time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC)
|
||||
secondReq := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "Second completion",
|
||||
CompletedAt: &secondCompletedAt,
|
||||
}
|
||||
resp, err := service.CreateCompletion(secondReq, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// NextDueDate should be Feb 15 + 30 days = Mar 17
|
||||
var taskAfterSecond models.Task
|
||||
db.First(&taskAfterSecond, task.ID)
|
||||
require.NotNil(t, taskAfterSecond.NextDueDate)
|
||||
expectedAfterSecond := secondCompletedAt.AddDate(0, 0, 30)
|
||||
assert.Equal(t, expectedAfterSecond.Year(), taskAfterSecond.NextDueDate.Year())
|
||||
assert.Equal(t, expectedAfterSecond.Month(), taskAfterSecond.NextDueDate.Month())
|
||||
assert.Equal(t, expectedAfterSecond.Day(), taskAfterSecond.NextDueDate.Day())
|
||||
|
||||
// Delete the second (latest) completion
|
||||
_, err = service.DeleteCompletion(resp.Data.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// NextDueDate should be recalculated from the first completion: Jan 15 + 30 = Feb 14
|
||||
var taskAfterDelete models.Task
|
||||
db.First(&taskAfterDelete, task.ID)
|
||||
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be set after deleting latest completion")
|
||||
expectedRecalculated := firstCompletedAt.AddDate(0, 0, 30)
|
||||
assert.Equal(t, expectedRecalculated.Year(), taskAfterDelete.NextDueDate.Year())
|
||||
assert.Equal(t, expectedRecalculated.Month(), taskAfterDelete.NextDueDate.Month())
|
||||
assert.Equal(t, expectedRecalculated.Day(), taskAfterDelete.NextDueDate.Day())
|
||||
}
|
||||
|
||||
func TestTaskService_DeleteCompletion_LastCompletion_RestoresDueDate(t *testing.T) {
|
||||
// Verifies P1-7: deleting the only completion on a recurring task
|
||||
// should restore NextDueDate to the original DueDate.
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
taskRepo := repositories.NewTaskRepository(db)
|
||||
residenceRepo := repositories.NewResidenceRepository(db)
|
||||
service := NewTaskService(taskRepo, residenceRepo)
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
var weeklyFrequency models.TaskFrequency
|
||||
db.Where("name = ?", "Weekly").First(&weeklyFrequency)
|
||||
|
||||
// Create a recurring task
|
||||
originalDueDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ResidenceID: residence.ID,
|
||||
CreatedByID: user.ID,
|
||||
Title: "Weekly Task",
|
||||
FrequencyID: &weeklyFrequency.ID,
|
||||
DueDate: &originalDueDate,
|
||||
NextDueDate: &originalDueDate,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
Version: 1,
|
||||
}
|
||||
err := db.Create(task).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete the task
|
||||
completedAt := time.Date(2026, 3, 2, 10, 0, 0, 0, time.UTC)
|
||||
req := &requests.CreateTaskCompletionRequest{
|
||||
TaskID: task.ID,
|
||||
Notes: "First completion",
|
||||
CompletedAt: &completedAt,
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
completionResp, err := service.CreateCompletion(req, user.ID, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify NextDueDate was set to completedAt + 7 days
|
||||
var taskAfterComplete models.Task
|
||||
db.First(&taskAfterComplete, task.ID)
|
||||
require.NotNil(t, taskAfterComplete.NextDueDate)
|
||||
|
||||
// Delete the only completion
|
||||
_, err = service.DeleteCompletion(completionResp.Data.ID, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// NextDueDate should be restored to original DueDate since no completions remain
|
||||
var taskAfterDelete models.Task
|
||||
db.First(&taskAfterDelete, task.ID)
|
||||
require.NotNil(t, taskAfterDelete.NextDueDate, "NextDueDate should be restored to original DueDate")
|
||||
assert.Equal(t, originalDueDate.Year(), taskAfterDelete.NextDueDate.Year())
|
||||
assert.Equal(t, originalDueDate.Month(), taskAfterDelete.NextDueDate.Month())
|
||||
assert.Equal(t, originalDueDate.Day(), taskAfterDelete.NextDueDate.Day())
|
||||
}
|
||||
|
||||
func TestTaskService_GetCategories(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
testutil.SeedLookupData(t, db)
|
||||
|
||||
Reference in New Issue
Block a user