🏡 index : ironmagma/quintodrome.git

package public

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/go-chi/jwtauth/v5"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/core/stream"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

type mockStreamer struct {
	req    stream.Request
	called bool
}

func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) {
	m.called = true
	m.req = r
	return nil, errors.New("mock: not implemented")
}

var _ = Describe("decodeStreamInfo", func() {
	BeforeEach(func() {
		auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
	})

	It("decodes a valid token with all fields", func() {
		claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
		info, err := decodeStreamInfo(token)
		Expect(err).NotTo(HaveOccurred())
		Expect(info.id).To(Equal("mf-123"))
		Expect(info.format).To(Equal("mp3"))
		Expect(info.bitrate).To(Equal(192))
		Expect(info.shareID).To(Equal("share123"))
	})

	It("rejects an expired token", func() {
		claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
		_, err := decodeStreamInfo(token)
		Expect(err).To(HaveOccurred())
	})

	It("accepts a token without exp (non-expiring share)", func() {
		claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
		token, _ := auth.CreatePublicToken(claims)
		info, err := decodeStreamInfo(token)
		Expect(err).NotTo(HaveOccurred())
		Expect(info.id).To(Equal("mf-123"))
		Expect(info.shareID).To(Equal("share123"))
	})

	It("rejects a token without an id claim", func() {
		claims := auth.Claims{ShareID: "share123"}
		token, _ := auth.CreatePublicToken(claims)
		_, err := decodeStreamInfo(token)
		Expect(err).To(HaveOccurred())
	})

	It("rejects an invalid token string", func() {
		_, err := decodeStreamInfo("not-a-valid-token")
		Expect(err).To(HaveOccurred())
	})

	It("handles tokens without shareID (backward compat)", func() {
		claims := auth.Claims{ID: "mf-123", Format: "opus"}
		token, _ := auth.CreatePublicToken(claims)
		info, err := decodeStreamInfo(token)
		Expect(err).NotTo(HaveOccurred())
		Expect(info.id).To(Equal("mf-123"))
		Expect(info.format).To(Equal("opus"))
		Expect(info.shareID).To(BeEmpty())
	})
})

var _ = Describe("encodeMediafileShare", func() {
	BeforeEach(func() {
		auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
	})

	It("includes the share ID in the token", func() {
		exp := new(time.Now().Add(time.Hour))
		s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp}
		token := encodeMediafileShare(s, "mf-999")
		info, err := decodeStreamInfo(token)
		Expect(err).NotTo(HaveOccurred())
		Expect(info.shareID).To(Equal("shareABC"))
		Expect(info.id).To(Equal("mf-999"))
		Expect(info.format).To(Equal("mp3"))
		Expect(info.bitrate).To(Equal(320))
	})

	It("creates a non-expiring token when share has no expiry", func() {
		s := model.Share{ID: "shareXYZ", ExpiresAt: nil}
		token := encodeMediafileShare(s, "mf-111")
		info, err := decodeStreamInfo(token)
		Expect(err).NotTo(HaveOccurred())
		Expect(info.shareID).To(Equal("shareXYZ"))
		Expect(info.id).To(Equal("mf-111"))
	})
})

var _ = Describe("handleStream", func() {
	var ds *tests.MockDataStore
	var shareRepo *tests.MockShareRepo
	var streamer *mockStreamer
	var pub *Router

	BeforeEach(func() {
		auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
		ds = &tests.MockDataStore{}
		shareRepo = &tests.MockShareRepo{}
		ds.MockedShare = shareRepo
		streamer = &mockStreamer{}
		pub = &Router{ds: ds, streamer: streamer}
	})

	makeRequest := func(token string) *httptest.ResponseRecorder {
		r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil)
		w := httptest.NewRecorder()
		pub.handleStream(w, r)
		return w
	}

	It("passes all validation and reaches the streamer for a valid token", func() {
		shareRepo.ID = "share123"
		mfRepo := tests.CreateMockMediaFileRepo()
		mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}})
		ds.MockedMediaFile = mfRepo

		claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
		makeRequest(token)

		Expect(streamer.called).To(BeTrue())
		Expect(streamer.req.Format).To(Equal("mp3"))
		Expect(streamer.req.BitRate).To(Equal(192))
	})

	It("returns 400 for an expired token", func() {
		claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims)
		w := makeRequest(token)
		Expect(w.Code).To(Equal(http.StatusBadRequest))
	})

	It("returns 404 when share has been deleted", func() {
		shareRepo.ID = "other-share"
		claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
		w := makeRequest(token)
		Expect(w.Code).To(Equal(http.StatusNotFound))
	})

	It("returns 410 when share has been set to expired", func() {
		shareRepo.ID = "share123"
		shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))}

		claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
		token, _ := auth.CreatePublicToken(claims)
		w := makeRequest(token)
		Expect(w.Code).To(Equal(http.StatusGone))
	})

	It("returns 500 when share lookup fails", func() {
		shareRepo.Error = errors.New("db error")
		claims := auth.Claims{ID: "mf-123", ShareID: "share123"}
		token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
		w := makeRequest(token)
		Expect(w.Code).To(Equal(http.StatusInternalServerError))
	})

	It("skips share check for tokens without shareID (backward compat)", func() {
		claims := auth.Claims{ID: "mf-123"}
		token, _ := auth.CreatePublicToken(claims)
		w := makeRequest(token)
		// Should get past share check, then fail on media file lookup (no mock data)
		Expect(w.Code).To(Equal(http.StatusNotFound))
	})

	It("returns 400 for an invalid token", func() {
		w := makeRequest("not-a-valid-token")
		Expect(w.Code).To(Equal(http.StatusBadRequest))
	})
})