mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
- Fold Allowed into Expand and drop the ErrInsufficientScope sentinel; the gate's scopeError is now the only source of insufficient_scope. - Replace the two-value authKind with a public flag, inline loadUser, and pass the grant to touch. - Read a declared request body once before validation, so the JSON checks and the handler no longer depend on kin-openapi restoring the exact bytes. - Merge the Service tests into one file and drop specs that only covered the removed liveness cache. The revoked-during-password-change spec now revokes after the gate authenticates, so it reaches ChangePassword again. Signed-off-by: Deluan <deluan@navidrome.org>
172 lines
6.9 KiB
Go
172 lines
6.9 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("insufficient scope", &scopeError{scope: "read"}, 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())
|
|
})
|
|
})
|
|
})
|