Files
honeyDueAPI/internal/handlers/contractor_handler_test.go
Trey t 7690f07a2b 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>
2026-03-02 09:48:01 -06:00

183 lines
6.4 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/labstack/echo/v4"
"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/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
)
func setupContractorHandler(t *testing.T) (*ContractorHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
contractorRepo := repositories.NewContractorRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
handler := NewContractorHandler(contractorService)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestContractorHandler_CreateContractor_MissingName_Returns400(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateContractor)
t.Run("missing name returns 400 validation error", func(t *testing.T) {
// Send request with no name (required field)
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should contain structured validation error
assert.Contains(t, response, "error")
assert.Contains(t, response, "fields")
fields := response["fields"].(map[string]interface{})
assert.Contains(t, fields, "name", "validation error should reference the 'name' field")
})
t.Run("empty body returns 400 validation error", func(t *testing.T) {
// Send completely empty body
w := testutil.MakeRequest(e, "POST", "/api/contractors/", map[string]interface{}{}, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "error")
})
t.Run("valid contractor creation succeeds", func(t *testing.T) {
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "John the Plumber",
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
})
}
func TestContractorHandler_ListContractors_Error_NoRawErrorInResponse(t *testing.T) {
_, e, db := setupContractorHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Create a handler with a broken service to simulate an internal error.
// We do this by closing the underlying SQL connection, which will cause
// the service to return an error on the next query.
brokenDB := testutil.SetupTestDB(t)
sqlDB, _ := brokenDB.DB()
sqlDB.Close()
brokenContractorRepo := repositories.NewContractorRepository(brokenDB)
brokenResidenceRepo := repositories.NewResidenceRepository(brokenDB)
brokenService := services.NewContractorService(brokenContractorRepo, brokenResidenceRepo)
brokenHandler := NewContractorHandler(brokenService)
authGroup := e.Group("/api/broken-contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", brokenHandler.ListContractors)
t.Run("internal error does not leak raw error message", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/broken-contractors/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should contain the generic error key, NOT a raw database error
errorMsg, ok := response["error"].(string)
require.True(t, ok, "response should have an 'error' string field")
// Must not contain database-specific details
assert.NotContains(t, errorMsg, "sql", "error message should not leak SQL details")
assert.NotContains(t, errorMsg, "database", "error message should not leak database details")
assert.NotContains(t, errorMsg, "closed", "error message should not leak connection state")
})
}
func TestContractorHandler_CreateContractor_100Specialties_Returns400(t *testing.T) {
handler, e, db := setupContractorHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/contractors")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateContractor)
t.Run("too many specialties rejected", func(t *testing.T) {
// Create a slice with 100 specialty IDs (exceeds max=20)
specialtyIDs := make([]uint, 100)
for i := range specialtyIDs {
specialtyIDs[i] = uint(i + 1)
}
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Over-specialized Contractor",
SpecialtyIDs: specialtyIDs,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("20 specialties accepted", func(t *testing.T) {
specialtyIDs := make([]uint, 20)
for i := range specialtyIDs {
specialtyIDs[i] = uint(i + 1)
}
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Multi-skilled Contractor",
SpecialtyIDs: specialtyIDs,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
// Should pass validation (201 or success, not 400)
assert.NotEqual(t, http.StatusBadRequest, w.Code, "20 specialties should pass validation")
})
t.Run("rating above 5 rejected", func(t *testing.T) {
rating := 6.0
req := requests.CreateContractorRequest{
ResidenceID: &residence.ID,
Name: "Bad Rating Contractor",
Rating: &rating,
}
w := testutil.MakeRequest(e, "POST", "/api/contractors/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}