mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-20 16:49:03 +02:00
refactor: standardize API error handling (#1635)
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The constructors in this file keep public messages, statuses, and details in one place
|
||||
|
||||
func AlreadyInUse(property string) *Error {
|
||||
return New(CodeAlreadyInUse, http.StatusConflict, property+" is already in use").WithDetail("property", property)
|
||||
}
|
||||
|
||||
func NotFound(resource string) *Error {
|
||||
return New(CodeNotFound, http.StatusNotFound, resource+" not found").WithDetail("resource", resource)
|
||||
}
|
||||
|
||||
func InvalidRequestBody(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidRequestBody, http.StatusBadRequest, "Request body is invalid")
|
||||
}
|
||||
|
||||
func MissingField(field string) *Error {
|
||||
return InvalidField(field, "required", "is required")
|
||||
}
|
||||
|
||||
func InvalidField(field, code, message string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, fmt.Sprintf("%s %s", field, message)).WithFields([]FieldError{{
|
||||
Field: field,
|
||||
Code: code,
|
||||
Message: message,
|
||||
}})
|
||||
}
|
||||
|
||||
func SetupNotAvailable() *Error {
|
||||
return New(CodeSetupNotAvailable, http.StatusNotFound, "Not found")
|
||||
}
|
||||
|
||||
func SetupAlreadyCompleted() *Error {
|
||||
return New(CodeSetupAlreadyCompleted, http.StatusConflict, "Initial setup has already been completed")
|
||||
}
|
||||
|
||||
func TokenInvalidOrExpired() *Error {
|
||||
return New(CodeTokenInvalidOrExpired, http.StatusUnauthorized, "Token is invalid or expired")
|
||||
}
|
||||
|
||||
func DeviceCodeInvalid() *Error {
|
||||
return New(CodeDeviceCodeInvalid, http.StatusUnauthorized, "One-time access code must be used on the device it was generated for")
|
||||
}
|
||||
|
||||
func TokenInvalid() *Error {
|
||||
return New(CodeInvalidToken, http.StatusUnauthorized, "Token is invalid")
|
||||
}
|
||||
|
||||
func OidcMissingAuthorization() *Error {
|
||||
return New(CodeOidcMissingAuthorization, http.StatusForbidden, "Authorization is missing")
|
||||
}
|
||||
|
||||
func OidcInvalidCallbackURL() *Error {
|
||||
return New(CodeOidcInvalidCallbackURL, http.StatusBadRequest, "Callback URL is invalid and may need to be corrected by an administrator")
|
||||
}
|
||||
|
||||
func InvalidCIMDURLPattern(pattern string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, "Metadata document URL pattern is invalid").
|
||||
WithDetail("pattern", pattern).
|
||||
WithFields([]FieldError{{
|
||||
Field: "cimdUrlAllowlist",
|
||||
Code: "invalid_value",
|
||||
Message: "contains an invalid URL pattern",
|
||||
}})
|
||||
}
|
||||
|
||||
func UnsupportedFileType(expected string) *Error {
|
||||
if expected == "" {
|
||||
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File type is not supported")
|
||||
}
|
||||
|
||||
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File must be of type "+expected).
|
||||
WithDetail("expected_file_type", expected)
|
||||
}
|
||||
|
||||
func FileTooLarge(maxSize string) *Error {
|
||||
return New(CodeFileTooLarge, http.StatusRequestEntityTooLarge, fmt.Sprintf("File must not exceed %s", maxSize)).WithDetail("max_size", maxSize)
|
||||
}
|
||||
|
||||
func NotSignedIn() *Error {
|
||||
return New(CodeNotSignedIn, http.StatusUnauthorized, "You are not signed in")
|
||||
}
|
||||
|
||||
func MissingPermission() *Error {
|
||||
return New(CodeForbidden, http.StatusForbidden, "You don't have permission to perform this action")
|
||||
}
|
||||
|
||||
func TooManyRequests() *Error {
|
||||
return New(CodeRateLimited, http.StatusTooManyRequests, "Too many requests")
|
||||
}
|
||||
|
||||
func UserNotFound() *Error {
|
||||
return New(CodeUserNotFound, http.StatusNotFound, "User not found")
|
||||
}
|
||||
|
||||
func InvalidImage(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidImage, http.StatusBadRequest, "File is not a valid image")
|
||||
}
|
||||
|
||||
func MissingSessionID() *Error {
|
||||
return New(CodeMissingSessionID, http.StatusBadRequest, "Session ID is missing")
|
||||
}
|
||||
|
||||
func InvalidWebAuthnSession() *Error {
|
||||
return New(CodeInvalidWebAuthnSession, http.StatusBadRequest, "Your passkey request has expired")
|
||||
}
|
||||
|
||||
func InvalidWebAuthnResponse(cause error) *Error {
|
||||
return Wrap(cause, CodeInvalidWebAuthnResponse, http.StatusBadRequest, "We couldn't process the response from your passkey")
|
||||
}
|
||||
|
||||
func WebAuthnAuthenticationFailed(cause error) *Error {
|
||||
return Wrap(cause, CodeWebAuthnAuthenticationFailed, http.StatusUnauthorized, "We couldn't verify your passkey")
|
||||
}
|
||||
|
||||
func PasskeyUserVerificationRequired(cause error) *Error {
|
||||
return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again")
|
||||
}
|
||||
|
||||
func ReservedClaim(key string) *Error {
|
||||
return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)).
|
||||
WithDetail("key", key).
|
||||
WithFields([]FieldError{{
|
||||
Field: "key",
|
||||
Code: "reserved",
|
||||
Message: "is reserved",
|
||||
}})
|
||||
}
|
||||
|
||||
func DuplicateClaim(key string) *Error {
|
||||
return New(CodeDuplicateClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is already defined", key)).
|
||||
WithDetail("key", key).
|
||||
WithFields([]FieldError{{
|
||||
Field: "key",
|
||||
Code: "duplicate",
|
||||
Message: "is listed more than once",
|
||||
}})
|
||||
}
|
||||
|
||||
func LdapDisabled() *Error {
|
||||
return New(CodeLdapDisabled, http.StatusConflict, "LDAP is not enabled")
|
||||
}
|
||||
|
||||
func LdapUserUpdate() *Error {
|
||||
return New(CodeLdapUserUpdate, http.StatusForbidden, "LDAP users can't be updated")
|
||||
}
|
||||
|
||||
func LdapUserGroupUpdate() *Error {
|
||||
return New(CodeLdapUserGroupUpdate, http.StatusForbidden, "LDAP user groups can't be updated")
|
||||
}
|
||||
|
||||
func OidcAccessDenied() *Error {
|
||||
return New(CodeOidcAccessDenied, http.StatusForbidden, "You're not allowed to access this service")
|
||||
}
|
||||
|
||||
func OidcInteractionNotFound() *Error {
|
||||
return New(CodeNotFound, http.StatusNotFound, "OIDC interaction not found or expired").
|
||||
WithDetail("resource", "OIDC interaction")
|
||||
}
|
||||
|
||||
func OidcClientIDNotMatching() *Error {
|
||||
return New(CodeOidcClientIDNotMatching, http.StatusBadRequest, "Client ID in request doesn't match client ID in token")
|
||||
}
|
||||
|
||||
func UIConfigDisabled() *Error {
|
||||
return New(CodeUIConfigDisabled, http.StatusForbidden, "The configuration can't be changed since the UI configuration is disabled")
|
||||
}
|
||||
|
||||
func InvalidUserID() *Error {
|
||||
return Validation([]FieldError{{
|
||||
Field: "userId",
|
||||
Code: "invalid_format",
|
||||
Message: "must be a valid UUID",
|
||||
}})
|
||||
}
|
||||
|
||||
func OneTimeAccessDisabled() *Error {
|
||||
return New(CodeOneTimeAccessDisabled, http.StatusForbidden, "One-time access is disabled")
|
||||
}
|
||||
|
||||
func DeviceLoginRequestInvalidOrExpired() *Error {
|
||||
return New(CodeDeviceLoginExpired, http.StatusUnauthorized, "Device login request is invalid or expired")
|
||||
}
|
||||
|
||||
func DeviceLoginDenied() *Error {
|
||||
return New(CodeDeviceLoginDenied, http.StatusForbidden, "Device login request was denied")
|
||||
}
|
||||
|
||||
func InvalidAPIKey() *Error {
|
||||
return New(CodeInvalidAPIKey, http.StatusUnauthorized, "API key is invalid")
|
||||
}
|
||||
|
||||
func NoAPIKeyProvided() *Error {
|
||||
return New(CodeNoAPIKeyProvided, http.StatusUnauthorized, "API key is missing")
|
||||
}
|
||||
|
||||
func APIKeyNotFound() *Error {
|
||||
return New(CodeAPIKeyNotFound, http.StatusNotFound, "API key not found")
|
||||
}
|
||||
|
||||
func APIKeyNotExpired() *Error {
|
||||
return New(CodeAPIKeyNotExpired, http.StatusConflict, "API key is not expired yet")
|
||||
}
|
||||
|
||||
func InvalidAPIKeyExpiration() *Error {
|
||||
return New(CodeInvalidAPIKeyExpiration, http.StatusBadRequest, "API key expiration time must be in the future").WithFields([]FieldError{{
|
||||
Field: "expiresAt",
|
||||
Code: "invalid_value",
|
||||
Message: "must be in the future",
|
||||
}})
|
||||
}
|
||||
|
||||
func APIKeyAuthNotAllowed() *Error {
|
||||
return New(CodeAPIKeyAuthNotAllowed, http.StatusForbidden, "API key authentication is not allowed for this endpoint")
|
||||
}
|
||||
|
||||
func UserDisabled() *Error {
|
||||
return New(CodeUserDisabled, http.StatusForbidden, "User account is disabled")
|
||||
}
|
||||
|
||||
func ValidationMessage(message string) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, message)
|
||||
}
|
||||
|
||||
func OidcDeviceCodeExpired() *Error {
|
||||
return New(CodeOidcDeviceCodeExpired, http.StatusBadRequest, "Device code has expired")
|
||||
}
|
||||
|
||||
func OidcInvalidDeviceCode() *Error {
|
||||
return New(CodeOidcInvalidDeviceCode, http.StatusBadRequest, "Device code is invalid")
|
||||
}
|
||||
|
||||
func ReauthenticationRequired() *Error {
|
||||
return New(CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
|
||||
}
|
||||
|
||||
func ReauthenticationRequiredWithCause(cause error) *Error {
|
||||
return Wrap(cause, CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
|
||||
}
|
||||
|
||||
func OpenSignupDisabled() *Error {
|
||||
return New(CodeOpenSignupDisabled, http.StatusForbidden, "Open user signup is not enabled")
|
||||
}
|
||||
|
||||
func ClientIDAlreadyExists() *Error {
|
||||
return New(CodeClientIDAlreadyExists, http.StatusConflict, "Client ID is already in use")
|
||||
}
|
||||
|
||||
func UserEmailNotSet() *Error {
|
||||
return New(CodeUserEmailNotSet, http.StatusConflict, "The user does not have an email address set")
|
||||
}
|
||||
|
||||
func ImageNotFound() *Error {
|
||||
return New(CodeImageNotFound, http.StatusNotFound, "Image not found")
|
||||
}
|
||||
|
||||
func LogoDownloadFailed(cause error) *Error {
|
||||
return Wrap(cause, CodeLogoDownloadFailed, http.StatusUnprocessableEntity, "Logo could not be downloaded")
|
||||
}
|
||||
|
||||
func LogoTypeNotSupported() *Error {
|
||||
return New(CodeLogoTypeNotSupported, http.StatusUnprocessableEntity, "Downloaded logo has an unsupported file type")
|
||||
}
|
||||
|
||||
func LogoTooLarge(maxSize string) *Error {
|
||||
return New(CodeLogoTooLarge, http.StatusUnprocessableEntity, fmt.Sprintf("Downloaded logo must not exceed %s", maxSize)).
|
||||
WithDetail("max_size", maxSize)
|
||||
}
|
||||
|
||||
func InvalidLogoURL(cause error) *Error {
|
||||
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
|
||||
Field: "logoUrl",
|
||||
Code: "invalid_value",
|
||||
Message: "must be a public HTTP or HTTPS URL",
|
||||
}})
|
||||
}
|
||||
|
||||
func OidcPARRequired() *Error {
|
||||
return New(CodeOidcPARRequired, http.StatusBadRequest, "This client requires pushed authorization requests")
|
||||
}
|
||||
|
||||
func InvalidEmailVerificationToken() *Error {
|
||||
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Code string
|
||||
|
||||
// #nosec G101 -- these are client-visible error codes, not credentials
|
||||
const (
|
||||
CodeInternal Code = "internal_error"
|
||||
CodeValidationFailed Code = "validation_failed"
|
||||
CodeInvalidRequestBody Code = "invalid_request_body"
|
||||
CodeRequestTimeout Code = "request_timeout"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeAlreadyInUse Code = "already_in_use"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeInvalidToken Code = "invalid_token"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeFileTooLarge Code = "file_too_large"
|
||||
CodeInvalidImage Code = "invalid_image"
|
||||
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
|
||||
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
|
||||
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
|
||||
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
|
||||
CodeUserNotFound Code = "user_not_found"
|
||||
CodeUserDisabled Code = "user_disabled"
|
||||
CodeAPIKeyNotFound Code = "api_key_not_found"
|
||||
CodeInvalidAPIKey Code = "invalid_api_key"
|
||||
CodeDeviceLoginExpired Code = "device_login_expired"
|
||||
CodeReauthenticationRequired Code = "reauthentication_required"
|
||||
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
|
||||
CodeSetupNotAvailable Code = "setup_not_available"
|
||||
CodeSetupAlreadyCompleted Code = "setup_already_completed"
|
||||
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
|
||||
CodeDeviceCodeInvalid Code = "device_code_invalid"
|
||||
CodeOidcMissingAuthorization Code = "oidc_missing_authorization"
|
||||
CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url"
|
||||
CodeFileTypeNotSupported Code = "file_type_not_supported"
|
||||
CodeNotSignedIn Code = "not_signed_in"
|
||||
CodeMissingSessionID Code = "missing_session_id"
|
||||
CodeReservedClaim Code = "reserved_claim"
|
||||
CodeDuplicateClaim Code = "duplicate_claim"
|
||||
CodeLdapDisabled Code = "ldap_disabled"
|
||||
CodeLdapUserUpdate Code = "ldap_user_update"
|
||||
CodeLdapUserGroupUpdate Code = "ldap_user_group_update"
|
||||
CodeOidcAccessDenied Code = "oidc_access_denied"
|
||||
CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching"
|
||||
CodeUIConfigDisabled Code = "ui_config_disabled"
|
||||
CodeOneTimeAccessDisabled Code = "one_time_access_disabled"
|
||||
CodeDeviceLoginDenied Code = "device_login_denied"
|
||||
CodeNoAPIKeyProvided Code = "no_api_key_provided"
|
||||
CodeAPIKeyNotExpired Code = "api_key_not_expired"
|
||||
CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration"
|
||||
CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed"
|
||||
CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired"
|
||||
CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code"
|
||||
CodeOpenSignupDisabled Code = "open_signup_disabled"
|
||||
CodeClientIDAlreadyExists Code = "client_id_already_exists"
|
||||
CodeUserEmailNotSet Code = "user_email_not_set"
|
||||
CodeImageNotFound Code = "image_not_found"
|
||||
CodeLogoDownloadFailed Code = "logo_download_failed"
|
||||
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
|
||||
CodeLogoTooLarge Code = "logo_too_large"
|
||||
CodeOidcPARRequired Code = "oidc_par_required"
|
||||
)
|
||||
|
||||
// FieldError describes one safe, client-actionable validation failure
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error is an application error with a stable code, an HTTP status, and an optional internal cause
|
||||
type Error struct {
|
||||
code Code
|
||||
status int
|
||||
message string
|
||||
details map[string]string
|
||||
fields []FieldError
|
||||
retryAfter time.Duration
|
||||
cause error
|
||||
}
|
||||
|
||||
// New creates a client-safe application error
|
||||
func New(code Code, status int, message string) *Error {
|
||||
return &Error{code: code, status: status, message: message}
|
||||
}
|
||||
|
||||
// Wrap creates a client-safe application error that retains an internal cause
|
||||
func Wrap(cause error, code Code, status int, message string) *Error {
|
||||
if cause == nil {
|
||||
return New(code, status, message)
|
||||
}
|
||||
|
||||
return &Error{code: code, status: status, message: message, cause: cause}
|
||||
}
|
||||
|
||||
// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients
|
||||
func Internal(cause error) *Error {
|
||||
return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong")
|
||||
}
|
||||
|
||||
// Validation creates a structured validation error
|
||||
func Validation(fields []FieldError) *Error {
|
||||
return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
|
||||
}
|
||||
|
||||
// Error returns diagnostic text including the internal cause when one exists
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if e.cause == nil {
|
||||
return e.message
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %v", e.message, e.cause)
|
||||
}
|
||||
|
||||
// Unwrap exposes the internal cause to errors.Is and errors.As
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// Is matches application errors by stable code while ignoring message and detail differences
|
||||
func (e *Error) Is(target error) bool {
|
||||
targetError, ok := target.(*Error)
|
||||
return ok && e != nil && targetError != nil && e.code == targetError.code
|
||||
}
|
||||
|
||||
// IsCode reports whether an error or one of its wrapped causes has the given application code
|
||||
func IsCode(err error, code Code) bool {
|
||||
return errors.Is(err, &Error{code: code})
|
||||
}
|
||||
|
||||
// Code returns the stable application error code
|
||||
func (e *Error) Code() Code {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.code
|
||||
}
|
||||
|
||||
// HTTPStatus returns the HTTP status associated with the error
|
||||
func (e *Error) HTTPStatus() int {
|
||||
if e == nil {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return e.status
|
||||
}
|
||||
|
||||
// ClientMessage returns the message that is safe to include in an HTTP response
|
||||
func (e *Error) ClientMessage() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Details returns a copy of the additional client-safe details
|
||||
func (e *Error) Details() map[string]string {
|
||||
if e == nil || len(e.details) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := make(map[string]string, len(e.details))
|
||||
maps.Copy(details, e.details)
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// Fields returns a copy of the structured validation details
|
||||
func (e *Error) Fields() []FieldError {
|
||||
if e == nil || len(e.fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return append([]FieldError(nil), e.fields...)
|
||||
}
|
||||
|
||||
// RetryAfter returns the duration a client should wait before retrying
|
||||
func (e *Error) RetryAfter() time.Duration {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return e.retryAfter
|
||||
}
|
||||
|
||||
// WithFields attaches structured validation details without exposing the cause
|
||||
func (e *Error) WithFields(fields []FieldError) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.fields = append([]FieldError(nil), fields...)
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDetail attaches one client-safe string detail
|
||||
func (e *Error) WithDetail(key, value string) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.details == nil {
|
||||
e.details = make(map[string]string)
|
||||
}
|
||||
e.details[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryAfter attaches a retry delay to the error
|
||||
func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
e.retryAfter = retryAfter
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package apperror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWrapPreservesCauseWithoutChangingClientMessage(t *testing.T) {
|
||||
cause := errors.New("database connection details")
|
||||
err := Wrap(cause, CodeNotFound, http.StatusNotFound, "User not found")
|
||||
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, "User not found", err.ClientMessage())
|
||||
require.Contains(t, err.Error(), "database connection details")
|
||||
}
|
||||
|
||||
func TestErrorsMatchByCode(t *testing.T) {
|
||||
err := Wrap(errors.New("database connection details"), CodeAlreadyInUse, http.StatusBadRequest, "email is already in use")
|
||||
wrapped := Wrap(err, CodeInternal, http.StatusInternalServerError, "request failed")
|
||||
|
||||
require.True(t, IsCode(err, CodeAlreadyInUse))
|
||||
require.True(t, IsCode(wrapped, CodeAlreadyInUse))
|
||||
require.ErrorIs(t, err, New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use"))
|
||||
require.NotErrorIs(t, err, errors.Join(New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use")))
|
||||
require.False(t, IsCode(wrapped, CodeNotFound))
|
||||
}
|
||||
|
||||
func TestErrorCopiesFields(t *testing.T) {
|
||||
fields := []FieldError{{Field: "email", Code: "required", Message: "is required"}}
|
||||
err := New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
|
||||
fields[0].Message = "changed"
|
||||
|
||||
require.Equal(t, "is required", err.Fields()[0].Message)
|
||||
}
|
||||
|
||||
func TestErrorCopiesDetails(t *testing.T) {
|
||||
err := New(CodeAlreadyInUse, http.StatusBadRequest, "Already in use").WithDetail("property", "email")
|
||||
details := err.Details()
|
||||
details["property"] = "password"
|
||||
|
||||
require.Equal(t, map[string]string{"property": "email"}, err.Details())
|
||||
}
|
||||
Reference in New Issue
Block a user