navidrome/server/nativeapi/native_api_song_test.go
Deluan Quintão b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in

Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.

- core/quickconnect: in-memory store shared by both routers through wire.
  Codes expire after 10 minutes; a secret redeems only once (Jellyfin
  allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
  Admins may approve for another user via UserId, like Swiftfin's admin page.
  Initiate and redeem share the login rate limiter; Connect does not, since
  Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
  the app and device before approving, so a user can't be tricked into
  approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
  when the Jellyfin API is enabled.

* refactor(jellyfin): tidy Quick Connect naming and route guards

Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.

* refactor(jellyfin): remove duplicated Jellyfin date formatting function

* test(jellyfin): set play count and starred in the song fixture literal

* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper

* fix(jellyfin): bound the client fields Quick Connect keeps in memory

Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.

Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.

* fix(jellyfin): rate-limit Quick Connect code approval

Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00

431 lines
12 KiB
Go

package nativeapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Song Endpoints", func() {
var (
router http.Handler
ds *tests.MockDataStore
mfRepo *tests.MockMediaFileRepo
userRepo *tests.MockedUserRepo
w *httptest.ResponseRecorder
testUser model.User
testSongs model.MediaFiles
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableSharing = false
conf.Server.SessionTimeout = time.Minute
// Setup mock repositories
mfRepo = tests.CreateMockMediaFileRepo()
userRepo = tests.CreateMockUserRepo()
ds = &tests.MockDataStore{
MockedMediaFile: mfRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
// Initialize auth system
auth.Init(ds)
// Create test user
testUser = model.User{
ID: "user-1",
UserName: "testuser",
Name: "Test User",
IsAdmin: false,
NewPassword: "testpass",
}
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
// Create test songs
testSongs = model.MediaFiles{
{
ID: "song-1",
Title: "Test Song 1",
Artist: "Test Artist 1",
Album: "Test Album 1",
AlbumID: "album-1",
ArtistID: "artist-1",
Duration: 180.5,
BitRate: 320,
Path: "/music/song1.mp3",
Suffix: "mp3",
Size: 5242880,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "song-2",
Title: "Test Song 2",
Artist: "Test Artist 2",
Album: "Test Album 2",
AlbumID: "album-2",
ArtistID: "artist-2",
Duration: 240.0,
BitRate: 256,
Path: "/music/song2.mp3",
Suffix: "mp3",
Size: 7340032,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
// Helper function to create unauthenticated request
createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
return req
}
// Helper function to create authenticated request with JWT token
createAuthenticatedRequest := func(method, path string, body []byte) *http.Request {
req := createUnauthenticatedRequest(method, path, body)
// Create JWT token for the test user
token, err := auth.CreateToken(&testUser)
Expect(err).ToNot(HaveOccurred())
// Add JWT token to Authorization header
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
return req
}
Describe("GET /song", func() {
Context("when user is authenticated", func() {
It("returns all songs", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
Expect(response[0].ID).To(Equal("song-1"))
Expect(response[0].Title).To(Equal("Test Song 1"))
Expect(response[1].ID).To(Equal("song-2"))
Expect(response[1].Title).To(Equal("Test Song 2"))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("GET /song/{id}", func() {
Context("when user is authenticated", func() {
It("returns the specific song", func() {
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).To(Equal("song-1"))
Expect(response.Title).To(Equal("Test Song 1"))
Expect(response.Artist).To(Equal("Test Artist 1"))
})
It("returns 404 for non-existent song", func() {
req := createAuthenticatedRequest("GET", "/song/non-existent", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("Song endpoints are read-only", func() {
Context("POST /song", func() {
It("should not be available (songs are not persistable)", func() {
newSong := model.MediaFile{
Title: "New Song",
Artist: "New Artist",
Album: "New Album",
Duration: 200.0,
}
body, _ := json.Marshal(newSong)
req := createAuthenticatedRequest("POST", "/song", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("PUT /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
updatedSong := model.MediaFile{
ID: "song-1",
Title: "Updated Song",
Artist: "Updated Artist",
Album: "Updated Album",
Duration: 250.0,
}
body, _ := json.Marshal(updatedSong)
req := createAuthenticatedRequest("PUT", "/song/song-1", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("DELETE /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
req := createAuthenticatedRequest("DELETE", "/song/song-1", nil)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
})
Describe("Query parameters and filtering", func() {
Context("when using query parameters", func() {
It("handles pagination parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Should still return all songs since our mock doesn't implement pagination
// but the request should be processed successfully
Expect(len(response)).To(BeNumerically(">=", 1))
})
It("handles sort parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
})
It("handles filter parameters", func() {
// Properly encode the URL with query parameters
baseURL := "/song"
params := url.Values{}
params.Add("title", "Test Song 1")
fullURL := baseURL + "?" + params.Encode()
req := createAuthenticatedRequest("GET", fullURL, nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Mock doesn't implement filtering, but request should be processed
Expect(len(response)).To(BeNumerically(">=", 1))
})
})
})
Describe("Response headers and content type", func() {
It("sets correct content type for JSON responses", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
})
It("includes total count header when available", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
// The X-Total-Count header might be set by the REST framework
// We just verify the request is processed successfully
})
})
Describe("Edge cases and error handling", func() {
Context("when repository is unavailable", func() {
It("handles database connection errors", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when no songs exist", func() {
It("returns empty array when no songs are found", func() {
mfRepo.SetData(model.MediaFiles{}) // Empty dataset
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(0))
})
})
})
Describe("Authentication middleware integration", func() {
Context("with different user types", func() {
It("works with admin users", func() {
adminUser := model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
err := userRepo.Put(&adminUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for admin user
token, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("works with regular users", func() {
regularUser := model.User{
ID: "user-2",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
err := userRepo.Put(&regularUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for regular user
token, err := auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Context("with missing authentication context", func() {
It("rejects requests without user context", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
// No authentication header added
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects requests with invalid JWT tokens", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})