mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-06 10:01:48 -04:00
Exposes core/artwork.Explain over GET /api/artwork/explain?kind=&id=, reporting stored trace and queue state without ever walking the chain live, so the response can only leak history the server already has.
220 lines
6.8 KiB
Go
220 lines
6.8 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/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.EnableSharing = false
|
|
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
|
ds = &tests.MockDataStore{}
|
|
auth.Init(ds)
|
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, 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.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]any)
|
|
Expect(ok).To(BeTrue())
|
|
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
|
|
|
// 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]any)
|
|
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]any)
|
|
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"))
|
|
})
|
|
|
|
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("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
|
|
}
|