navidrome/server/apiv1/problem_test.go
Deluan d368eef3ce fix(log): redact named string types instead of panicking
The redaction hook matched fields by reflect.Kind but read them with a
v.(string) type assertion, so any named string type (such as an enum)
panicked the log call. API v1 problem logging hit this on every error.
2026-09-28 20:06:28 -04:00

173 lines
7 KiB
Go

package apiv1
import (
"context"
"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 ctx context.Context
var w *httptest.ResponseRecorder
var r *http.Request
BeforeEach(func() {
ctx = GinkgoT().Context()
w = httptest.NewRecorder()
r = httptest.NewRequestWithContext(ctx, 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("writes and logs a problem with debug logging on", func() {
logs := captureLogs()
writeProblem(w, r, model.ErrNotFound)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(logs.String()).To(ContainSubstring("code=not_found"))
})
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("challenges a 401 with invalid_token when the request carried a bearer token", func() {
r.Header.Set("Authorization", "Bearer tok")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
w = httptest.NewRecorder()
r.Header.Set("Authorization", "Basic dXNlcjpwdw==")
writeProblem(w, r, model.ErrInvalidAuth)
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
})
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())
})
})
})