🏡 index : ironmagma/quintodrome.git

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),
	)
})