Deluan Quintão 6dd98e0bed
feat(ui): add configuration tab in About dialog ()
* Flatten config endpoint and improve About dialog

* add config resource

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): replace `==` with `===`

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add environment variables

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add sensitive value redaction

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): more translations

Signed-off-by: Deluan <deluan@navidrome.org>

* address PR comments

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add configuration export feature in About dialog

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): translate development flags section header

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(api): refactor routes for keepalive and insights endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* lint

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): enhance string escaping in formatTomlValue function

Updated the formatTomlValue function to properly escape backslashes in addition to quotes. Added new test cases to ensure correct handling of strings containing both backslashes and quotes.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): adjust dialog size

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-30 21:07:08 -04:00

269 lines
9.0 KiB
Go

package nativeapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("config endpoint", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("rejects non admin users", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("returns configuration entries", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
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"))
// Verify that we have both Dev and non-Dev fields
var hasDevFields = false
var hasNonDevFields = false
for _, e := range resp.Config {
if strings.HasPrefix(e.Key, "Dev") {
hasDevFields = true
} else {
hasNonDevFields = true
}
}
Expect(hasDevFields).To(BeTrue(), "Should have Dev* configuration fields")
Expect(hasNonDevFields).To(BeTrue(), "Should have non-Dev configuration fields")
Expect(len(resp.Config)).To(BeNumerically(">", 0), "Should return configuration entries")
})
It("includes flattened struct fields", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
Expect(values).To(HaveKeyWithValue("Inspect.MaxRequests", "1"))
Expect(values).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "DENY"))
})
It("includes the config file path", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ConfigFile).To(Not(BeEmpty()))
})
It("includes environment variable names", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Create a map to check specific env var mappings
envVars := map[string]string{}
for _, e := range resp.Config {
envVars[e.Key] = e.EnvVar
}
Expect(envVars).To(HaveKeyWithValue("MusicFolder", "ND_MUSICFOLDER"))
Expect(envVars).To(HaveKeyWithValue("Scanner.Enabled", "ND_SCANNER_ENABLED"))
Expect(envVars).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "ND_HTTPSECURITYHEADERS_CUSTOMFRAMEOPTIONSVALUE"))
})
Context("redaction functionality", func() {
It("redacts sensitive values with partial masking for long values", func() {
// Set up test values
conf.Server.LastFM.ApiKey = "ba46f0e84a123456"
conf.Server.Spotify.Secret = "verylongsecret123"
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
Expect(values).To(HaveKeyWithValue("LastFM.ApiKey", "b**************6"))
Expect(values).To(HaveKeyWithValue("Spotify.Secret", "v***************3"))
})
It("redacts sensitive values with full masking for short values", func() {
// Set up test values with short secrets
conf.Server.LastFM.Secret = "short"
conf.Server.Spotify.ID = "abc123"
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
Expect(values).To(HaveKeyWithValue("LastFM.Secret", "****"))
Expect(values).To(HaveKeyWithValue("Spotify.ID", "****"))
})
It("fully masks password fields", func() {
// Set up test values for password fields
conf.Server.DevAutoCreateAdminPassword = "adminpass123"
conf.Server.Prometheus.Password = "prometheuspass"
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
Expect(values).To(HaveKeyWithValue("DevAutoCreateAdminPassword", "****"))
Expect(values).To(HaveKeyWithValue("Prometheus.Password", "****"))
})
It("does not redact non-sensitive values", func() {
conf.Server.MusicFolder = "/path/to/music"
conf.Server.Port = 4533
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
Expect(values).To(HaveKeyWithValue("MusicFolder", "/path/to/music"))
Expect(values).To(HaveKeyWithValue("Port", "4533"))
})
It("handles empty sensitive values", func() {
conf.Server.LastFM.ApiKey = ""
conf.Server.PasswordEncryptionKey = ""
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{}
for _, e := range resp.Config {
if s, ok := e.Value.(string); ok {
values[e.Key] = s
}
}
// Empty sensitive values should remain empty
Expect(values["LastFM.ApiKey"]).To(Equal(""))
Expect(values["PasswordEncryptionKey"]).To(Equal(""))
})
})
})
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"))
})
})