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

@@ -0,0 +1,334 @@
package handlers
import (
"net/http"
"testing"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
)
// TestTaskHandler_NoAuth_Returns401 verifies that task handler endpoints return
// 401 Unauthorized when no auth user is set in the context (e.g., auth middleware
// misconfigured or bypassed). This is a regression test for P1-1 (SEC-19).
func TestTaskHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskService := services.NewTaskService(taskRepo, residenceRepo)
handler := NewTaskHandler(taskService, nil)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/tasks/", handler.ListTasks)
e.GET("/api/tasks/:id/", handler.GetTask)
e.POST("/api/tasks/", handler.CreateTask)
e.PUT("/api/tasks/:id/", handler.UpdateTask)
e.DELETE("/api/tasks/:id/", handler.DeleteTask)
e.POST("/api/tasks/:id/cancel/", handler.CancelTask)
e.POST("/api/tasks/:id/mark-in-progress/", handler.MarkInProgress)
e.GET("/api/task-completions/", handler.ListCompletions)
e.POST("/api/task-completions/", handler.CreateCompletion)
tests := []struct {
name string
method string
path string
}{
{"ListTasks", "GET", "/api/tasks/"},
{"GetTask", "GET", "/api/tasks/1/"},
{"CreateTask", "POST", "/api/tasks/"},
{"UpdateTask", "PUT", "/api/tasks/1/"},
{"DeleteTask", "DELETE", "/api/tasks/1/"},
{"CancelTask", "POST", "/api/tasks/1/cancel/"},
{"MarkInProgress", "POST", "/api/tasks/1/mark-in-progress/"},
{"ListCompletions", "GET", "/api/task-completions/"},
{"CreateCompletion", "POST", "/api/task-completions/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestResidenceHandler_NoAuth_Returns401 verifies that residence handler endpoints
// return 401 Unauthorized when no auth user is set in the context.
func TestResidenceHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
handler := NewResidenceHandler(residenceService, nil, nil, true)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/residences/", handler.ListResidences)
e.GET("/api/residences/my/", handler.GetMyResidences)
e.GET("/api/residences/summary/", handler.GetSummary)
e.GET("/api/residences/:id/", handler.GetResidence)
e.POST("/api/residences/", handler.CreateResidence)
e.PUT("/api/residences/:id/", handler.UpdateResidence)
e.DELETE("/api/residences/:id/", handler.DeleteResidence)
e.POST("/api/residences/:id/generate-share-code/", handler.GenerateShareCode)
e.POST("/api/residences/join-with-code/", handler.JoinWithCode)
e.GET("/api/residences/:id/users/", handler.GetResidenceUsers)
tests := []struct {
name string
method string
path string
}{
{"ListResidences", "GET", "/api/residences/"},
{"GetMyResidences", "GET", "/api/residences/my/"},
{"GetSummary", "GET", "/api/residences/summary/"},
{"GetResidence", "GET", "/api/residences/1/"},
{"CreateResidence", "POST", "/api/residences/"},
{"UpdateResidence", "PUT", "/api/residences/1/"},
{"DeleteResidence", "DELETE", "/api/residences/1/"},
{"GenerateShareCode", "POST", "/api/residences/1/generate-share-code/"},
{"JoinWithCode", "POST", "/api/residences/join-with-code/"},
{"GetResidenceUsers", "GET", "/api/residences/1/users/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestNotificationHandler_NoAuth_Returns401 verifies that notification handler
// endpoints return 401 Unauthorized when no auth user is set in the context.
func TestNotificationHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
notificationRepo := repositories.NewNotificationRepository(db)
notificationService := services.NewNotificationService(notificationRepo, nil)
handler := NewNotificationHandler(notificationService)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/notifications/", handler.ListNotifications)
e.GET("/api/notifications/unread-count/", handler.GetUnreadCount)
e.POST("/api/notifications/:id/read/", handler.MarkAsRead)
e.POST("/api/notifications/mark-all-read/", handler.MarkAllAsRead)
e.GET("/api/notifications/preferences/", handler.GetPreferences)
e.PUT("/api/notifications/preferences/", handler.UpdatePreferences)
e.POST("/api/notifications/devices/", handler.RegisterDevice)
e.GET("/api/notifications/devices/", handler.ListDevices)
tests := []struct {
name string
method string
path string
}{
{"ListNotifications", "GET", "/api/notifications/"},
{"GetUnreadCount", "GET", "/api/notifications/unread-count/"},
{"MarkAsRead", "POST", "/api/notifications/1/read/"},
{"MarkAllAsRead", "POST", "/api/notifications/mark-all-read/"},
{"GetPreferences", "GET", "/api/notifications/preferences/"},
{"UpdatePreferences", "PUT", "/api/notifications/preferences/"},
{"RegisterDevice", "POST", "/api/notifications/devices/"},
{"ListDevices", "GET", "/api/notifications/devices/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestDocumentHandler_NoAuth_Returns401 verifies that document handler endpoints
// return 401 Unauthorized when no auth user is set in the context.
func TestDocumentHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
documentRepo := repositories.NewDocumentRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
documentService := services.NewDocumentService(documentRepo, residenceRepo)
handler := NewDocumentHandler(documentService, nil)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/documents/", handler.ListDocuments)
e.GET("/api/documents/:id/", handler.GetDocument)
e.GET("/api/documents/warranties/", handler.ListWarranties)
e.POST("/api/documents/", handler.CreateDocument)
e.PUT("/api/documents/:id/", handler.UpdateDocument)
e.DELETE("/api/documents/:id/", handler.DeleteDocument)
e.POST("/api/documents/:id/activate/", handler.ActivateDocument)
e.POST("/api/documents/:id/deactivate/", handler.DeactivateDocument)
tests := []struct {
name string
method string
path string
}{
{"ListDocuments", "GET", "/api/documents/"},
{"GetDocument", "GET", "/api/documents/1/"},
{"ListWarranties", "GET", "/api/documents/warranties/"},
{"CreateDocument", "POST", "/api/documents/"},
{"UpdateDocument", "PUT", "/api/documents/1/"},
{"DeleteDocument", "DELETE", "/api/documents/1/"},
{"ActivateDocument", "POST", "/api/documents/1/activate/"},
{"DeactivateDocument", "POST", "/api/documents/1/deactivate/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestContractorHandler_NoAuth_Returns401 verifies that contractor handler endpoints
// return 401 Unauthorized when no auth user is set in the context.
func TestContractorHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
handler := NewContractorHandler(contractorService)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/contractors/", handler.ListContractors)
e.GET("/api/contractors/:id/", handler.GetContractor)
e.POST("/api/contractors/", handler.CreateContractor)
e.PUT("/api/contractors/:id/", handler.UpdateContractor)
e.DELETE("/api/contractors/:id/", handler.DeleteContractor)
e.POST("/api/contractors/:id/toggle-favorite/", handler.ToggleFavorite)
e.GET("/api/contractors/:id/tasks/", handler.GetContractorTasks)
tests := []struct {
name string
method string
path string
}{
{"ListContractors", "GET", "/api/contractors/"},
{"GetContractor", "GET", "/api/contractors/1/"},
{"CreateContractor", "POST", "/api/contractors/"},
{"UpdateContractor", "PUT", "/api/contractors/1/"},
{"DeleteContractor", "DELETE", "/api/contractors/1/"},
{"ToggleFavorite", "POST", "/api/contractors/1/toggle-favorite/"},
{"GetContractorTasks", "GET", "/api/contractors/1/tasks/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestSubscriptionHandler_NoAuth_Returns401 verifies that subscription handler
// endpoints return 401 Unauthorized when no auth user is set in the context.
func TestSubscriptionHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
handler := NewSubscriptionHandler(subscriptionService)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/subscription/", handler.GetSubscription)
e.GET("/api/subscription/status/", handler.GetSubscriptionStatus)
e.GET("/api/subscription/promotions/", handler.GetPromotions)
e.POST("/api/subscription/purchase/", handler.ProcessPurchase)
e.POST("/api/subscription/cancel/", handler.CancelSubscription)
e.POST("/api/subscription/restore/", handler.RestoreSubscription)
tests := []struct {
name string
method string
path string
}{
{"GetSubscription", "GET", "/api/subscription/"},
{"GetSubscriptionStatus", "GET", "/api/subscription/status/"},
{"GetPromotions", "GET", "/api/subscription/promotions/"},
{"ProcessPurchase", "POST", "/api/subscription/purchase/"},
{"CancelSubscription", "POST", "/api/subscription/cancel/"},
{"RestoreSubscription", "POST", "/api/subscription/restore/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestMediaHandler_NoAuth_Returns401 verifies that media handler endpoints return
// 401 Unauthorized when no auth user is set in the context.
func TestMediaHandler_NoAuth_Returns401(t *testing.T) {
handler := NewMediaHandler(nil, nil, nil, nil)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/media/document/:id", handler.ServeDocument)
e.GET("/api/media/document-image/:id", handler.ServeDocumentImage)
e.GET("/api/media/completion-image/:id", handler.ServeCompletionImage)
tests := []struct {
name string
method string
path string
}{
{"ServeDocument", "GET", "/api/media/document/1"},
{"ServeDocumentImage", "GET", "/api/media/document-image/1"},
{"ServeCompletionImage", "GET", "/api/media/completion-image/1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}
// TestUserHandler_NoAuth_Returns401 verifies that user handler endpoints return
// 401 Unauthorized when no auth user is set in the context.
func TestUserHandler_NoAuth_Returns401(t *testing.T) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
userService := services.NewUserService(userRepo)
handler := NewUserHandler(userService)
e := testutil.SetupTestRouter()
// Register routes WITHOUT auth middleware
e.GET("/api/users/", handler.ListUsers)
e.GET("/api/users/:id/", handler.GetUser)
e.GET("/api/users/profiles/", handler.ListProfiles)
tests := []struct {
name string
method string
path string
}{
{"ListUsers", "GET", "/api/users/"},
{"GetUser", "GET", "/api/users/1/"},
{"ListProfiles", "GET", "/api/users/profiles/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := testutil.MakeRequest(e, tt.method, tt.path, nil, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
}