Files
navidrome/server/subsonic/api_test.go
Deluan 1e7996f5d7 fix(share): enforce per-user ownership on share reads
Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count,
CountAll) did not apply an owner filter, so non-admin users saw shares
belonging to other users. The write paths already enforced per-user ownership;
this brings reads in line with them.

Add an addRestriction()/ownerFilter() based scope to share reads, keeping
admins and the headless public-share resolution path unrestricted. Route share
and player Delete through a new base-repo deleteOwned() primitive that applies
the ownership predicate in the DELETE's WHERE clause (atomic, no select-then-
delete window) and classifies a zero-row result as permission-denied vs
not-found, mirroring updateOwned. The addRestriction helper and the write-miss
classifier are hoisted onto the base repository so player and share share one
implementation.

Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error
handler so ownership/not-found failures from the rest-backed repositories
return the proper Subsonic codes (50 / 70) instead of a generic error.

Covered by unit tests (persistence, subsonic error mapping) and an end-to-end
cross-user sharing isolation test.
2026-06-05 15:50:59 -04:00

214 lines
6.9 KiB
Go

package subsonic
import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sendResponse", func() {
var (
w *httptest.ResponseRecorder
r *http.Request
payload *responses.Subsonic
)
BeforeEach(func() {
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/somepath", nil)
payload = &responses.Subsonic{
Status: responses.StatusOK,
Version: "1.16.1",
}
})
When("format is JSON", func() {
It("should set Content-Type to application/json and return the correct body", func() {
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
Expect(w.Body.String()).NotTo(BeEmpty())
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
})
})
When("format is JSONP", func() {
It("should set Content-Type to application/javascript and return the correct callback body", func() {
q := r.URL.Query()
q.Add("f", "jsonp")
q.Add("callback", "testCallback")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/javascript"))
body := w.Body.String()
Expect(body).To(SatisfyAll(
HavePrefix("testCallback("),
HaveSuffix(")"),
))
// Extract JSON from the JSONP response
jsonBody := body[strings.Index(body, "(")+1 : strings.LastIndex(body, ")")]
var wrapper responses.JsonWrapper
err := json.Unmarshal([]byte(jsonBody), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
})
It("should accept valid callback names with dots", func() {
q := r.URL.Query()
q.Add("f", "jsonp")
q.Add("callback", "jQuery.callback_123")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
body := w.Body.String()
Expect(body).To(HavePrefix("jQuery.callback_123("))
})
It("should reject callback with invalid characters", func() {
q := r.URL.Query()
q.Add("f", "jsonp")
q.Add("callback", "alert(1)//")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("invalid callback parameter"))
})
It("should reject empty callback parameter", func() {
q := r.URL.Query()
q.Add("f", "jsonp")
q.Add("callback", "")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
})
})
When("format is XML or unspecified", func() {
It("should set Content-Type to application/xml and return the correct body", func() {
// No format specified, expecting XML by default
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/xml"))
var subsonicResponse responses.Subsonic
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
Expect(err).NotTo(HaveOccurred())
Expect(subsonicResponse.Status).To(Equal(payload.Status))
Expect(subsonicResponse.Version).To(Equal(payload.Version))
})
})
When("an error occurs during marshalling", func() {
It("should return a fail response", func() {
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
// An +Inf value will cause an error when marshalling to JSON
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))}
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("json: unsupported value: +Inf"))
})
})
It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() {
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/rest/stream", nil)
sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes))
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
var subsonicResponse responses.Subsonic
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
Expect(err).NotTo(HaveOccurred())
Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed))
Expect(subsonicResponse.Error).ToNot(BeNil())
Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric))
Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode"))
})
It("updates status pointer when an error occurs", func() {
pointer := int32(0)
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet
r = r.WithContext(ctx)
payload.Status = responses.StatusFailed
payload.Error = &responses.Error{Code: responses.ErrorDataNotFound}
sendResponse(w, r, payload)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(pointer).To(Equal(responses.ErrorDataNotFound))
})
})
var _ = Describe("mapToSubsonicError", func() {
DescribeTable("maps repository errors to the correct Subsonic error code",
func(err error, expectedCode int32) {
subErr := mapToSubsonicError(err)
Expect(subErr.code).To(Equal(expectedCode))
},
Entry("rest.ErrPermissionDenied -> not authorized (50)",
rest.ErrPermissionDenied, responses.ErrorAuthorizationFail),
Entry("rest.ErrNotFound -> data not found (70)",
rest.ErrNotFound, responses.ErrorDataNotFound),
Entry("model.ErrNotAuthorized -> not authorized (50)",
model.ErrNotAuthorized, responses.ErrorAuthorizationFail),
Entry("model.ErrNotFound -> data not found (70)",
model.ErrNotFound, responses.ErrorDataNotFound),
Entry("wrapped rest.ErrPermissionDenied is still mapped",
fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail),
Entry("unknown error -> generic (0)",
errors.New("boom"), responses.ErrorGeneric),
)
})