Files
honeyDueAPI/internal/handlers/residence_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

570 lines
20 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/config"
"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"
"gorm.io/gorm"
)
func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *echo.Echo, *gorm.DB) {
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()
return handler, e, db
}
func TestResidenceHandler_CreateResidence(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
t.Run("successful creation", func(t *testing.T) {
req := requests.CreateResidenceRequest{
Name: "My House",
StreetAddress: "123 Main St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, "My House", residenceData["name"])
assert.Equal(t, "123 Main St", residenceData["street_address"])
assert.Equal(t, "Austin", residenceData["city"])
assert.Equal(t, "TX", residenceData["state_province"])
assert.Equal(t, "78701", residenceData["postal_code"])
assert.Equal(t, "USA", residenceData["country"]) // Default
assert.Equal(t, true, residenceData["is_primary"])
})
t.Run("creation with optional fields", func(t *testing.T) {
bedrooms := 3
bathrooms := decimal.NewFromFloat(2.5)
sqft := 2000
isPrimary := false
req := requests.CreateResidenceRequest{
Name: "Second House",
StreetAddress: "456 Oak Ave",
City: "Dallas",
StateProvince: "TX",
PostalCode: "75001",
Country: "USA",
Bedrooms: &bedrooms,
Bathrooms: &bathrooms,
SquareFootage: &sqft,
IsPrimary: &isPrimary,
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, float64(3), residenceData["bedrooms"])
assert.Equal(t, "2.5", residenceData["bathrooms"]) // Decimal serializes as string
assert.Equal(t, float64(2000), residenceData["square_footage"])
// Note: first residence becomes primary by default even if is_primary=false is specified
assert.Contains(t, []interface{}{true, false}, residenceData["is_primary"])
})
t.Run("creation with missing required fields", func(t *testing.T) {
// Only name is required; address fields are optional
req := map[string]string{
// Missing name - this is required
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidence(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetResidence)
otherAuthGroup := e.Group("/api/other-residences")
otherAuthGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherAuthGroup.GET("/:id/", handler.GetResidence)
t.Run("get own residence", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Test House", response["name"])
assert.Equal(t, float64(residence.ID), response["id"])
})
t.Run("get residence with invalid ID", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("get non-existent residence", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/9999/", nil, "test-token")
// Returns 403 (access denied) rather than 404 to not reveal whether an ID exists
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_ListResidences(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListResidences)
t.Run("list residences", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 2)
})
}
func TestResidenceHandler_UpdateResidence(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name")
// Share with user
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateResidence)
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.PUT("/:id/", handler.UpdateResidence)
t.Run("owner can update", func(t *testing.T) {
newName := "Updated Name"
newCity := "Dallas"
req := requests.UpdateResidenceRequest{
Name: &newName,
City: &newCity,
}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
assert.Equal(t, "Updated Name", residenceData["name"])
assert.Equal(t, "Dallas", residenceData["city"])
})
t.Run("shared user cannot update", func(t *testing.T) {
newName := "Hacked Name"
req := requests.UpdateResidenceRequest{
Name: &newName,
}
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_DeleteResidence(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "To Delete")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteResidence)
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.DELETE("/:id/", handler.DeleteResidence)
t.Run("shared user cannot delete", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("owner can delete", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
assert.Contains(t, response["data"], "deleted")
})
}
func TestResidenceHandler_GenerateShareCode(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Share Test")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode)
t.Run("generate share code", func(t *testing.T) {
req := requests.GenerateShareCodeRequest{
ExpiresInHours: 24,
}
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
shareCode := response["share_code"].(map[string]interface{})
code := shareCode["code"].(string)
assert.Len(t, code, 6)
assert.NotEmpty(t, shareCode["expires_at"])
})
}
func TestResidenceHandler_JoinWithCode(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Join Test")
// Generate share code first
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
shareResp, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(newUser))
authGroup.POST("/join-with-code/", handler.JoinWithCode)
ownerGroup := e.Group("/api/owner-residences")
ownerGroup.Use(testutil.MockAuthMiddleware(owner))
ownerGroup.POST("/join-with-code/", handler.JoinWithCode)
t.Run("join with valid code", func(t *testing.T) {
req := requests.JoinWithCodeRequest{
Code: shareResp.ShareCode.Code,
}
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// JoinResidenceResponse includes summary
assert.Contains(t, response, "residence")
assert.Contains(t, response, "summary")
residenceResp := response["residence"].(map[string]interface{})
assert.Equal(t, "Join Test", residenceResp["name"])
})
t.Run("owner tries to join own residence", func(t *testing.T) {
// Generate new code
shareResp2, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24)
req := requests.JoinWithCodeRequest{
Code: shareResp2.ShareCode.Code,
}
w := testutil.MakeRequest(e, "POST", "/api/owner-residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusConflict)
})
t.Run("join with invalid code", func(t *testing.T) {
req := requests.JoinWithCodeRequest{
Code: "ABCDEF", // Valid length (6) but non-existent code
}
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Users Test")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.GET("/:id/users/", handler.GetResidenceUsers)
t.Run("get residence users", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 2) // owner + shared user
})
}
func TestResidenceHandler_RemoveUser(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Remove Test")
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser)
t.Run("remove shared user", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["message"], "removed")
})
t.Run("cannot remove owner", func(t *testing.T) {
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidenceTypes(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/types/", handler.GetResidenceTypes)
t.Run("get residence types", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/types/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Greater(t, len(response), 0)
})
}
func TestResidenceHandler_JSONResponses(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
authGroup.GET("/", handler.ListResidences)
t.Run("residence response has correct JSON structure", func(t *testing.T) {
req := requests.CreateResidenceRequest{
Name: "JSON Test House",
StreetAddress: "123 Test St",
City: "Austin",
StateProvince: "TX",
PostalCode: "78701",
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be wrapped in WithSummaryResponse
assert.Contains(t, response, "data")
assert.Contains(t, response, "summary")
residenceData := response["data"].(map[string]interface{})
// Required fields in residence data
assert.Contains(t, residenceData, "id")
assert.Contains(t, residenceData, "name")
assert.Contains(t, residenceData, "street_address")
assert.Contains(t, residenceData, "city")
assert.Contains(t, residenceData, "state_province")
assert.Contains(t, residenceData, "postal_code")
assert.Contains(t, residenceData, "country")
assert.Contains(t, residenceData, "is_primary")
assert.Contains(t, residenceData, "is_active")
assert.Contains(t, residenceData, "created_at")
assert.Contains(t, residenceData, "updated_at")
// Type checks
assert.IsType(t, float64(0), residenceData["id"])
assert.IsType(t, "", residenceData["name"])
assert.IsType(t, true, residenceData["is_primary"])
// Summary should have expected fields
summary := response["summary"].(map[string]interface{})
assert.Contains(t, summary, "total_residences")
assert.Contains(t, summary, "total_tasks")
})
t.Run("list response returns array", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Response should be an array of residences
assert.IsType(t, []map[string]interface{}{}, response)
})
}
func TestResidenceHandler_CreateResidence_NegativeBedrooms_Returns400(t *testing.T) {
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
t.Run("negative bedrooms rejected", func(t *testing.T) {
bedrooms := -1
req := requests.CreateResidenceRequest{
Name: "Bad House",
Bedrooms: &bedrooms,
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("negative square footage rejected", func(t *testing.T) {
sqft := -100
req := requests.CreateResidenceRequest{
Name: "Bad House",
SquareFootage: &sqft,
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("zero bedrooms accepted", func(t *testing.T) {
bedrooms := 0
req := requests.CreateResidenceRequest{
Name: "Studio Apartment",
Bedrooms: &bedrooms,
}
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
})
}