mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
* feat: add album refresh functionality after deleting missing files Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets. Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing. Signed-off-by: Deluan <deluan@navidrome.org> * refactor: extract missing files deletion into reusable service layer Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability. The MissingFiles service handles: - Deletion of specific or all missing files via transaction - Garbage collection after deletion - Extraction of affected album IDs from missing files - Background refresh of artist and album statistics The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go. Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency. * refactor: consolidate maintenance operations into unified service Consolidate MissingFiles and RefreshAlbums functionality into a new Maintenance service. This refactoring: - Creates core.Maintenance interface combining DeleteMissingFiles, DeleteAllMissingFiles, and RefreshAlbums methods - Moves RefreshAlbums logic from AlbumRepository persistence layer to core Maintenance service - Removes MissingFiles interface and moves its implementation to maintenanceService - Updates all references in wire providers, native API router, and handlers - Removes RefreshAlbums interface method from AlbumRepository model - Improves separation of concerns by centralizing maintenance operations in the core domain This change provides a cleaner API and better organization of maintenance-related database operations. * refactor: remove MissingFiles interface and update references Remove obsolete MissingFiles interface and its references: - Delete core/missing_files.go and core/missing_files_test.go - Remove RefreshAlbums method from AlbumRepository interface and implementation - Remove RefreshAlbums tests from AlbumRepository test suite - Update wire providers to use NewMaintenance instead of NewMissingFiles - Update native API router to use Maintenance service - Update missing.go handler to use Maintenance interface All functionality is now consolidated in the core.Maintenance service. Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename RefreshAlbums to refreshAlbums and update related calls Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize album refresh logic and improve test coverage Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify logging setup in tests with reusable LogHook function Signed-off-by: Deluan <deluan@navidrome.org> * refactor: add synchronization to logger and maintenance service for thread safety Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
228 lines
7.1 KiB
Go
228 lines
7.1 KiB
Go
package nativeapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core"
|
|
"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("Config API", func() {
|
|
var ds model.DataStore
|
|
var router http.Handler
|
|
var adminUser, regularUser model.User
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
|
ds = &tests.MockDataStore{}
|
|
auth.Init(ds)
|
|
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
|
router = server.JWTVerifier(nativeRouter)
|
|
|
|
// Create test users
|
|
adminUser = model.User{
|
|
ID: "admin-1",
|
|
UserName: "admin",
|
|
Name: "Admin User",
|
|
IsAdmin: true,
|
|
NewPassword: "adminpass",
|
|
}
|
|
regularUser = model.User{
|
|
ID: "user-1",
|
|
UserName: "regular",
|
|
Name: "Regular User",
|
|
IsAdmin: false,
|
|
NewPassword: "userpass",
|
|
}
|
|
|
|
// Store in mock datastore
|
|
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
|
|
Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed())
|
|
})
|
|
|
|
Describe("GET /api/config", func() {
|
|
Context("as admin user", func() {
|
|
var adminToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
adminToken, err = auth.CreateToken(&adminUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("returns config successfully", func() {
|
|
req := createAuthenticatedConfigRequest(adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var resp configResponse
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
|
Expect(resp.ID).To(Equal("config"))
|
|
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
|
|
Expect(resp.Config).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("redacts sensitive fields", func() {
|
|
conf.Server.LastFM.ApiKey = "secretapikey123"
|
|
conf.Server.Spotify.Secret = "spotifysecret456"
|
|
conf.Server.PasswordEncryptionKey = "encryptionkey789"
|
|
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
|
conf.Server.Prometheus.Password = "prometheuspass"
|
|
|
|
req := createAuthenticatedConfigRequest(adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var resp configResponse
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
|
|
|
// Check LastFM.ApiKey (partially masked)
|
|
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
|
Expect(ok).To(BeTrue())
|
|
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
|
|
|
// Check Spotify.Secret (partially masked)
|
|
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
|
|
Expect(ok).To(BeTrue())
|
|
Expect(spotify["Secret"]).To(Equal("s**************6"))
|
|
|
|
// Check PasswordEncryptionKey (fully masked)
|
|
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
|
|
|
|
// Check DevAutoCreateAdminPassword (fully masked)
|
|
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
|
|
|
|
// Check Prometheus.Password (fully masked)
|
|
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
|
|
Expect(ok).To(BeTrue())
|
|
Expect(prometheus["Password"]).To(Equal("****"))
|
|
})
|
|
|
|
It("handles empty sensitive values", func() {
|
|
conf.Server.LastFM.ApiKey = ""
|
|
conf.Server.PasswordEncryptionKey = ""
|
|
|
|
req := createAuthenticatedConfigRequest(adminToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var resp configResponse
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
|
|
|
// Check LastFM.ApiKey - should be preserved because it's sensitive
|
|
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
|
Expect(ok).To(BeTrue())
|
|
Expect(lastfm["ApiKey"]).To(Equal(""))
|
|
|
|
// Empty sensitive values should remain empty - should be preserved because it's sensitive
|
|
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Context("as regular user", func() {
|
|
var userToken string
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
userToken, err = auth.CreateToken(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("denies access with forbidden status", func() {
|
|
req := createAuthenticatedConfigRequest(userToken)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
})
|
|
|
|
Context("without authentication", func() {
|
|
It("denies access with unauthorized status", func() {
|
|
req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(w, req)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("redactValue function", func() {
|
|
It("partially masks long sensitive values", func() {
|
|
Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
|
|
Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
|
|
})
|
|
|
|
It("fully masks long sensitive values that should be completely hidden", func() {
|
|
Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
|
|
Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
|
|
Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
|
|
})
|
|
|
|
It("fully masks short sensitive values", func() {
|
|
Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
|
|
Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
|
|
Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
|
|
Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
|
|
Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
|
|
})
|
|
|
|
It("does not mask non-sensitive values", func() {
|
|
Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
|
|
Expect(redactValue("Port", "4533")).To(Equal("4533"))
|
|
Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
|
|
})
|
|
|
|
It("handles empty values", func() {
|
|
Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
|
|
Expect(redactValue("NonSensitive", "")).To(Equal(""))
|
|
})
|
|
|
|
It("handles edge case values", func() {
|
|
Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
|
|
Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
|
|
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
|
|
})
|
|
})
|
|
|
|
// Helper functions
|
|
|
|
func createAuthenticatedConfigRequest(token string) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
|
|
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|
|
|
|
func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request {
|
|
if body == nil {
|
|
body = &bytes.Buffer{}
|
|
}
|
|
req := httptest.NewRequest(method, path, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|