navidrome/server/apiv1/problem_test.go

152 lines
6.2 KiB
Go

package apiv1
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func decodeProblem(w *httptest.ResponseRecorder) Problem {
var p Problem
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed())
return p
}
var _ = Describe("problem", func() {
var w *httptest.ResponseRecorder
var r *http.Request
BeforeEach(func() {
w = httptest.NewRecorder()
r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)
})
Describe("writeProblem", func() {
DescribeTable("maps domain errors to status and code",
func(err error, status int, code ProblemCode) {
writeProblem(w, r, err)
Expect(w.Code).To(Equal(status))
Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType))
p := decodeProblem(w)
Expect(p.Status).To(Equal(status))
Expect(p.Code).To(Equal(code))
Expect(p.Title).To(Equal(http.StatusText(status)))
Expect(p.Type).To(BeNil())
Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`))
},
Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound),
Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden),
Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
Entry("token expired", apiauth.ErrTokenExpired, http.StatusUnauthorized, ProblemCodeTokenExpired),
Entry("insufficient scope", apiauth.ErrInsufficientScope, http.StatusForbidden, ProblemCodeInsufficientScope),
Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete),
Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally),
Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal),
)
It("shows detail only for errors marked as client-facing", func() {
writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound))
Expect(decodeProblem(w).Detail).To(BeNil())
w = httptest.NewRecorder()
writeProblem(w, r, ClientError(model.ErrNotFound, "album not found"))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeNotFound))
Expect(*p.Detail).To(Equal("album not found"))
})
It("writes field errors from validationFailed", func() {
writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
p := decodeProblem(w)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
})
It("adds a Bearer challenge to every 401 unless one is already set", func() {
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
w = httptest.NewRecorder()
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("adds the request's referenceId to internal errors only", func() {
r = r.WithContext(withReferenceID(r.Context(), "ref-123"))
writeProblem(w, r, errors.New("boom"))
Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123"))
w = httptest.NewRecorder()
writeProblem(w, r, model.ErrNotFound)
Expect(decodeProblem(w).ReferenceId).To(BeNil())
})
It("hides details for internal errors", func() {
writeProblem(w, r, errors.New("db password is hunter2"))
p := decodeProblem(w)
Expect(p.Detail).To(BeNil())
})
})
Describe("writeProblemStatus", func() {
It("writes field errors only when provided", func() {
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input",
ValidationError{Field: "limit", Message: "must be <= 2000"})
p := decodeProblem(w)
Expect(p.Errors).ToNot(BeNil())
Expect(*p.Errors).To(HaveLen(1))
Expect((*p.Errors)[0].Field).To(Equal("limit"))
})
It("omits detail when empty", func() {
writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "")
Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`))
Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`))
})
})
Describe("bindingErrorHandler", func() {
DescribeTable("maps parameter binding errors to a validation problem with the field",
func(err error, field, message string) {
bindingErrorHandler(w, r, err)
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(p.Errors).ToNot(BeNil())
Expect(*p.Errors).To(HaveLen(1))
Expect((*p.Errors)[0].Field).To(Equal(field))
Expect((*p.Errors)[0].Message).To(Equal(message))
},
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"),
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"),
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"),
)
It("never echoes the submitted value", func() {
bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)})
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
})
It("still returns a validation problem for unknown binding errors", func() {
bindingErrorHandler(w, r, errors.New("weird"))
p := decodeProblem(w)
Expect(p.Status).To(Equal(http.StatusBadRequest))
Expect(p.Code).To(Equal(ProblemCodeValidation))
Expect(p.Errors).To(BeNil())
})
})
})