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:
334
internal/handlers/noauth_test.go
Normal file
334
internal/handlers/noauth_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user