mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-21 09:09:04 +02:00
refactor: standardize API error handling (#1635)
This commit is contained in:
@@ -3,7 +3,7 @@ package middleware
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -39,15 +39,15 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI
|
||||
|
||||
user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
return "", false, apperror.NotSignedIn()
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return "", false, &common.UserDisabledError{}
|
||||
return "", false, apperror.UserDisabled()
|
||||
}
|
||||
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
return "", false, apperror.MissingPermission()
|
||||
}
|
||||
|
||||
return user.ID, user.IsAdmin, nil
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -88,8 +86,8 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// If JWT auth failed and the error is not a NotSignedInError, abort the request
|
||||
if !errors.Is(err, &common.NotSignedInError{}) {
|
||||
// If JWT auth failed for a reason other than missing credentials, abort the request
|
||||
if !apperror.IsCode(err, apperror.CodeNotSignedIn) {
|
||||
c.Abort()
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -103,7 +101,7 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
|
||||
c.Abort()
|
||||
if c.GetHeader("X-API-Key") != "" {
|
||||
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
|
||||
_ = c.Error(apperror.APIKeyAuthNotAllowed())
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
|
||||
@@ -1,125 +1,278 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
type requestIDContextKey struct{}
|
||||
type requestErrorCodeContextKey struct{}
|
||||
|
||||
// RequestID returns the identifier assigned to the current request
|
||||
func RequestID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
requestID, exists := c.Get(requestIDContextKey{})
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := requestID.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// RequestErrorCode returns the stable code of the error handled for the current request
|
||||
func RequestErrorCode(c *gin.Context) apperror.Code {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
code, exists := c.Get(requestErrorCodeContextKey{})
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := code.(apperror.Code)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||
return &ErrorHandlerMiddleware{}
|
||||
}
|
||||
|
||||
type classifiedError struct {
|
||||
code apperror.Code
|
||||
status int
|
||||
message string
|
||||
details map[string]string
|
||||
fields []apperror.FieldError
|
||||
retryAfter time.Duration
|
||||
}
|
||||
|
||||
// Add records a request ID before executing the request and serializes the first returned error afterward
|
||||
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := uuid.NewString()
|
||||
c.Set(requestIDContextKey{}, requestID)
|
||||
c.Header(requestIDHeader, requestID)
|
||||
|
||||
c.Next()
|
||||
for _, err := range c.Errors {
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
svErr, ok := errors.AsType[binding.SliceValidationError](err)
|
||||
if ok {
|
||||
if errors.As(svErr[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// AppError with description
|
||||
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
|
||||
if ok {
|
||||
errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description())
|
||||
return
|
||||
}
|
||||
|
||||
// AppError (without description)
|
||||
appErr, ok := errors.AsType[common.AppError](err)
|
||||
if ok {
|
||||
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, errorResponseBody{
|
||||
Error: "Something went wrong",
|
||||
})
|
||||
if len(c.Errors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := c.Errors[0].Err
|
||||
classified := classifyError(err)
|
||||
c.Set(requestErrorCodeContextKey{}, classified.code)
|
||||
logRequestError(c, err, classified, requestID)
|
||||
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if classified.retryAfter > 0 {
|
||||
c.Header("Retry-After", formatRetryAfter(classified.retryAfter))
|
||||
}
|
||||
writeErrorResponse(c, classified, requestID)
|
||||
}
|
||||
}
|
||||
|
||||
type errorResponseBody struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Error string `json:"error"`
|
||||
Code apperror.Code `json:"code"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, errorResponseBody{
|
||||
Error: message,
|
||||
})
|
||||
}
|
||||
|
||||
func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, errorResponseBody{
|
||||
Error: message,
|
||||
ErrorDescription: description,
|
||||
})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
var errorMessages []string
|
||||
|
||||
for _, ve := range validationErrors {
|
||||
fieldName := ve.Field()
|
||||
var errorMessage string
|
||||
switch ve.Tag() {
|
||||
case "required":
|
||||
errorMessage = fmt.Sprintf("%s is required", fieldName)
|
||||
case "email":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||
case "username":
|
||||
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "resource_uri":
|
||||
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
|
||||
case "min":
|
||||
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||
case "max":
|
||||
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
func classifyError(err error) classifiedError {
|
||||
var structuredErr *apperror.Error
|
||||
if errors.As(err, &structuredErr) && structuredErr != nil {
|
||||
return classifiedError{
|
||||
code: structuredErr.Code(),
|
||||
status: normalizeStatus(structuredErr.HTTPStatus()),
|
||||
message: capitalizeFirst(structuredErr.ClientMessage()),
|
||||
details: structuredErr.Details(),
|
||||
fields: structuredErr.Fields(),
|
||||
retryAfter: structuredErr.RetryAfter(),
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, errorMessage)
|
||||
}
|
||||
|
||||
// Join all the error messages into a single string
|
||||
combinedErrors := strings.Join(errorMessages, ", ")
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return classifiedError{
|
||||
code: apperror.CodeRequestTimeout,
|
||||
status: http.StatusGatewayTimeout,
|
||||
message: "Request timed out",
|
||||
}
|
||||
}
|
||||
|
||||
return combinedErrors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) && len(validationErrors) > 0 {
|
||||
return classifiedValidationError(validationErrors)
|
||||
}
|
||||
|
||||
var sliceValidationErrors binding.SliceValidationError
|
||||
if errors.As(err, &sliceValidationErrors) && len(sliceValidationErrors) > 0 {
|
||||
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||
return classifiedValidationError(validationErrors)
|
||||
}
|
||||
}
|
||||
|
||||
return classifiedError{
|
||||
code: apperror.CodeInternal,
|
||||
status: http.StatusInternalServerError,
|
||||
message: "Something went wrong",
|
||||
}
|
||||
}
|
||||
|
||||
func classifiedValidationError(validationErrors validator.ValidationErrors) classifiedError {
|
||||
fields := make([]apperror.FieldError, 0, len(validationErrors))
|
||||
messages := make([]string, 0, len(validationErrors))
|
||||
|
||||
for _, validationError := range validationErrors {
|
||||
fieldName := validationError.Field()
|
||||
code, message := validationFieldError(validationError)
|
||||
fields = append(fields, apperror.FieldError{
|
||||
Field: fieldName,
|
||||
Code: code,
|
||||
Message: message,
|
||||
})
|
||||
messages = append(messages, fieldName+" "+message)
|
||||
}
|
||||
|
||||
return classifiedError{
|
||||
code: apperror.CodeValidationFailed,
|
||||
status: http.StatusBadRequest,
|
||||
message: capitalizeFirst(strings.Join(messages, ", ")),
|
||||
fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func validationFieldError(validationError validator.FieldError) (string, string) {
|
||||
switch validationError.Tag() {
|
||||
case "required":
|
||||
return "required", "is required"
|
||||
case "email":
|
||||
return "invalid_format", "must be a valid email address"
|
||||
case "username":
|
||||
return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character"
|
||||
case "url":
|
||||
return "invalid_format", "must be a valid URL"
|
||||
case "resource_uri":
|
||||
return "invalid_format", "must be an absolute URI without whitespace or a fragment"
|
||||
case "min":
|
||||
return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param())
|
||||
case "max":
|
||||
return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param())
|
||||
default:
|
||||
return validationError.Tag(), "is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) {
|
||||
details := make(map[string]any, len(classified.details)+1)
|
||||
for key, value := range classified.details {
|
||||
details[key] = value
|
||||
}
|
||||
if len(classified.fields) > 0 {
|
||||
details["fields"] = classified.fields
|
||||
}
|
||||
if len(details) == 0 {
|
||||
details = nil
|
||||
}
|
||||
|
||||
response := errorResponseBody{
|
||||
Error: classified.message,
|
||||
Code: classified.code,
|
||||
Details: details,
|
||||
RequestID: requestID,
|
||||
}
|
||||
|
||||
c.JSON(classified.status, response)
|
||||
}
|
||||
|
||||
func logRequestError(c *gin.Context, err error, classified classifiedError, requestID string) {
|
||||
if classified.status < http.StatusInternalServerError {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
slog.String("error_code", string(classified.code)),
|
||||
slog.String("error_type", errorTypeName(err)),
|
||||
slog.Int("http_status", classified.status),
|
||||
slog.String("request_id", requestID),
|
||||
slog.String("http_method", c.Request.Method),
|
||||
slog.String("http_path", c.Request.URL.Path),
|
||||
slog.Any("error", err),
|
||||
}
|
||||
if spanContext := trace.SpanFromContext(c.Request.Context()).SpanContext(); spanContext.IsValid() {
|
||||
attrs = append(attrs, slog.String("trace_id", spanContext.TraceID().String()))
|
||||
}
|
||||
|
||||
slog.ErrorContext(c.Request.Context(), "Request failed", attrs...)
|
||||
}
|
||||
|
||||
func errorTypeName(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
|
||||
return reflect.TypeOf(err).String()
|
||||
}
|
||||
|
||||
func normalizeStatus(status int) int {
|
||||
if status < http.StatusBadRequest || status > 599 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func formatRetryAfter(retryAfter time.Duration) string {
|
||||
seconds := int(retryAfter / time.Second)
|
||||
if retryAfter%time.Second != 0 {
|
||||
seconds++
|
||||
}
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", seconds)
|
||||
}
|
||||
|
||||
func capitalizeFirst(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) == 0 {
|
||||
return message
|
||||
}
|
||||
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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 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)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
)
|
||||
|
||||
type FileSizeLimitMiddleware struct{}
|
||||
@@ -18,7 +20,13 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||
// Classify only size-limit failures as file_too_large so malformed multipart bodies remain invalid requests
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) || errors.Is(err, multipart.ErrMessageTooLarge) {
|
||||
err = apperror.FileTooLarge(formatFileSize(maxSize))
|
||||
} else {
|
||||
err = apperror.InvalidRequestBody(err)
|
||||
}
|
||||
_ = c.Error(err)
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFileSizeLimitMiddlewareClassifiesMultipartErrors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("oversized body", func(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "large.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(bytes.Repeat([]byte("x"), 128))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
recorder := serveMultipartRequest(t, body.Bytes(), writer.FormDataContentType(), 64)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
|
||||
require.Equal(t, apperror.CodeFileTooLarge, responseCode(t, recorder))
|
||||
})
|
||||
|
||||
t.Run("malformed body", func(t *testing.T) {
|
||||
recorder := serveMultipartRequest(t, []byte("not multipart"), "multipart/form-data; boundary=missing", 1024)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, apperror.CodeInvalidRequestBody, responseCode(t, recorder))
|
||||
})
|
||||
}
|
||||
|
||||
func serveMultipartRequest(t *testing.T, body []byte, contentType string, maxSize int64) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.POST("/", NewFileSizeLimitMiddleware().Add(maxSize), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func responseCode(t *testing.T, recorder *httptest.ResponseRecorder) apperror.Code {
|
||||
t.Helper()
|
||||
|
||||
var body struct {
|
||||
Code apperror.Code `json:"code"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
return body.Code
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
@@ -44,37 +44,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
|
||||
var ok bool
|
||||
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
|
||||
if !ok || accessToken == "" {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
}
|
||||
|
||||
token, err := m.jwtService.VerifyAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
authenticationTime, _ = token.IssuedAt()
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok {
|
||||
_ = c.Error(&common.TokenInvalidError{})
|
||||
return "", false, "", time.Time{}, &common.TokenInvalidError{}
|
||||
_ = c.Error(apperror.TokenInvalid())
|
||||
return "", false, "", time.Time{}, apperror.TokenInvalid()
|
||||
}
|
||||
|
||||
user, err := m.userService.GetUser(c, subject)
|
||||
if err != nil {
|
||||
return "", false, "", time.Time{}, &common.NotSignedInError{}
|
||||
return "", false, "", time.Time{}, apperror.NotSignedIn()
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return "", false, "", time.Time{}, &common.UserDisabledError{}
|
||||
return "", false, "", time.Time{}, apperror.UserDisabled()
|
||||
}
|
||||
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, "", time.Time{}, &common.MissingPermissionError{}
|
||||
return "", false, "", time.Time{}, apperror.MissingPermission()
|
||||
}
|
||||
|
||||
return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil
|
||||
|
||||
@@ -3,16 +3,15 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
@@ -82,11 +81,12 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// A missing service means the policy was never registered on the actor host, which is a development-time errror
|
||||
// A missing service means the policy was never registered on the actor host, which is a development-time error
|
||||
svc := m.services[policy]
|
||||
if svc == nil {
|
||||
return func(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
_ = c.Error(apperror.Internal(fmt.Errorf("rate limiter service is not configured for policy %q", policy)))
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +113,8 @@ func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
// Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
|
||||
}
|
||||
_ = c.Error(&common.TooManyRequestsError{})
|
||||
// Advertise when the caller may retry, mapping the limiter's delay onto a structured application error
|
||||
_ = c.Error(apperror.TooManyRequests().WithRetryAfter(retryAfter))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
t.Run("fails with 500 when the policy is not registered", func(t *testing.T) {
|
||||
// An unknown policy has no bound service, which is a configuration error surfaced as a 500
|
||||
r := newRateLimitRouter(t, services, "does-not-exist")
|
||||
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
|
||||
w := doRateLimitRequest(t.Context(), r, "203.0.113.6")
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"code":"internal_error"`)
|
||||
assert.NotContains(t, w.Body.String(), "rate limiter service is not configured")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user