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:
Trey t
2026-03-02 09:48:01 -06:00
parent 56d6fa4514
commit 7690f07a2b
123 changed files with 8321 additions and 750 deletions

View File

@@ -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)