package middleware import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/httpserver" "github.com/stretchr/testify/require" "gorm.io/gorm" ) func TestErrorHandlerMiddlewareStructuredError(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) var handlerRequestID string router.GET("/users/1", httpserver.Handle(func(c *gin.Context) error { handlerRequestID = RequestID(c) cause := errors.New("database connection details") return apperror.Wrap(cause, apperror.CodeUserNotFound, http.StatusNotFound, "User not found") })) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users/1", nil) router.ServeHTTP(recorder, request) var body struct { Error string `json:"error"` Code apperror.Code `json:"code"` Details map[string]string `json:"details"` RequestID string `json:"request_id"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusNotFound, recorder.Code) require.Equal(t, "User not found", body.Error) require.Equal(t, apperror.CodeUserNotFound, body.Code) require.Empty(t, body.Details) require.NotEmpty(t, body.RequestID) require.Equal(t, body.RequestID, handlerRequestID) require.Equal(t, body.RequestID, recorder.Header().Get(requestIDHeader)) require.NotContains(t, recorder.Body.String(), "database connection details") } func TestErrorHandlerMiddlewareHidesUnexpectedError(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.GET("/failure", func(c *gin.Context) { _ = c.Error(errors.New("private database details")) c.Abort() }) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/failure", nil) router.ServeHTTP(recorder, request) var body map[string]any require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusInternalServerError, recorder.Code) require.Equal(t, "Something went wrong", body["error"]) require.Equal(t, string(apperror.CodeInternal), body["code"]) require.NotContains(t, recorder.Body.String(), "private database details") require.NotEmpty(t, body["request_id"]) } func TestErrorHandlerMiddlewareHidesRecoveredPanic(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.GET("/panic", httpserver.Handle(func(*gin.Context) error { panic("private panic details") })) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", nil) router.ServeHTTP(recorder, request) var body map[string]any require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusInternalServerError, recorder.Code) require.Equal(t, "Something went wrong", body["error"]) require.Equal(t, string(apperror.CodeInternal), body["code"]) require.NotContains(t, recorder.Body.String(), "private panic details") require.NotEmpty(t, body["request_id"]) } func TestErrorHandlerMiddlewareIncludesSafeDetails(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.GET("/users", func(c *gin.Context) { _ = c.Error(apperror.AlreadyInUse("email")) c.Abort() }) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/users", nil) router.ServeHTTP(recorder, request) var body struct { Details map[string]string `json:"details"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, map[string]string{"property": "email"}, body.Details) } func TestErrorHandlerMiddlewareSetsRetryAfter(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.GET("/limited", func(c *gin.Context) { _ = c.Error(apperror.TooManyRequests().WithRetryAfter(1500 * time.Millisecond)) c.Abort() }) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/limited", nil) router.ServeHTTP(recorder, request) require.Equal(t, http.StatusTooManyRequests, recorder.Code) require.Equal(t, "2", recorder.Header().Get("Retry-After")) } func TestValidationResponseUsesJSONFieldNames(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.POST("/users", httpserver.Handle(func(c *gin.Context) error { var input dto.UserCreateDto return httpserver.BindJSON(c, &input) })) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/users", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json") router.ServeHTTP(recorder, request) var body struct { Details struct { Fields []apperror.FieldError `json:"fields"` } `json:"details"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusBadRequest, recorder.Code) require.Contains(t, body.Details.Fields, apperror.FieldError{ Field: "username", Code: "required", Message: "is required", }) } func TestValidationResponseUsesSpecificCustomMessages(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.POST("/config", httpserver.Handle(func(c *gin.Context) error { var input struct { SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"json_custom_claims"` } return httpserver.BindJSON(c, &input) })) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/config", strings.NewReader(`{"signupDefaultCustomClaims":"[\"immich_role\": \"user\"]"}`)) request.Header.Set("Content-Type", "application/json") router.ServeHTTP(recorder, request) var body struct { Error string `json:"error"` Details struct { Fields []apperror.FieldError `json:"fields"` } `json:"details"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusBadRequest, recorder.Code) require.Equal(t, `SignupDefaultCustomClaims must be a JSON array of objects with string "key" and "value" properties`, body.Error) require.Equal(t, []apperror.FieldError{{ Field: "signupDefaultCustomClaims", Code: "invalid_format", Message: `must be a JSON array of objects with string "key" and "value" properties`, }}, body.Details.Fields) } func TestValidationResponseUsesAppConfigTypeMessages(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() router.Use(NewErrorHandlerMiddleware().Add()) router.POST("/config", httpserver.Handle(func(c *gin.Context) error { var input dto.AppConfigUpdateDto return httpserver.BindJSON(c, &input) })) input := dto.AppConfigUpdateDto{ AppName: "Pocket ID", SessionDuration: "60", HomePageURL: "/settings/account", EmailsVerified: "false", DisableAnimations: "false", AllowOwnAccountEdit: "true", AllowUserSignups: "disabled", RequireUserEmail: "hello", SmtpTls: "none", SmtpSkipCertVerify: "false", LdapEnabled: "false", LdapSkipCertVerify: "false", LdapSoftDeleteUsers: "true", WebauthnUserVerification: "preferred", WebauthnAllowSyncedPasskeys: "true", WebauthnAuthenticatorAttachment: "any", EmailOneTimeAccessAsAdminEnabled: "false", EmailOneTimeAccessAsUnauthenticatedEnabled: "false", EmailLoginNotificationEnabled: "false", EmailApiKeyExpirationEnabled: "false", EmailVerificationEnabled: "false", } payload, err := json.Marshal(input) require.NoError(t, err) recorder := httptest.NewRecorder() request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/config", strings.NewReader(string(payload))) request.Header.Set("Content-Type", "application/json") router.ServeHTTP(recorder, request) var body struct { Error string `json:"error"` Details struct { Fields []apperror.FieldError `json:"fields"` } `json:"details"` } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) require.Equal(t, http.StatusBadRequest, recorder.Code) require.Equal(t, "RequireUserEmail must be either true or false", body.Error) require.Equal(t, []apperror.FieldError{{ Field: "requireUserEmail", Code: "invalid_format", Message: "must be either true or false", }}, body.Details.Fields) } func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) { classified := classifyError(gorm.ErrRecordNotFound) require.Equal(t, apperror.CodeInternal, classified.code) require.Equal(t, http.StatusInternalServerError, classified.status) } func TestClassifyDeadlineAsRequestTimeout(t *testing.T) { classified := classifyError(context.DeadlineExceeded) require.Equal(t, apperror.CodeRequestTimeout, classified.code) require.Equal(t, http.StatusGatewayTimeout, classified.status) require.Equal(t, "Request timed out", classified.message) }