194 lines
6.2 KiB
Go
194 lines
6.2 KiB
Go
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))
|
|
})
|
|
})
|