refactor: simplify configuration endpoint with JSON serialization (#4159)

* refactor(config): reorganize configuration handling

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

* refactor(aboutUtils): improve array formatting and handling in TOML conversion

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

* refactor(aboutUtils): add escapeTomlKey function to handle special characters in TOML keys

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

* fix(test): remove unused getNestedValue function

* fix(ui): apply prettier formatting

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-05-31 19:37:23 -04:00
committed by GitHub
parent 8e32eeae93
commit 36ed2f2f58
6 changed files with 598 additions and 248 deletions
+21 -22
View File
@@ -66,6 +66,7 @@ type configOptions struct {
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
@@ -86,25 +87,23 @@ type configOptions struct {
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Backup backupOptions
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
LyricsPriority string
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
Backup backupOptions `json:",omitzero"`
PID pidOptions `json:",omitzero"`
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevLogLevels map[string]string
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
@@ -146,12 +145,12 @@ type subsonicOptions struct {
}
type TagConf struct {
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"`
Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"`
Split []string `yaml:"split"`
Album bool `yaml:"album"`
Ignore bool `yaml:"ignore" json:",omitempty"`
Aliases []string `yaml:"aliases" json:",omitempty"`
Type string `yaml:"type" json:",omitempty"`
MaxLength int `yaml:"maxLength" json:",omitempty"`
Split []string `yaml:"split" json:",omitempty"`
Album bool `yaml:"album" json:",omitempty"`
}
type lastfmOptions struct {
+50 -45
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/navidrome/navidrome/conf"
@@ -36,16 +35,10 @@ var sensitiveFieldsFullMask = []string{
"Prometheus.Password",
}
type configEntry struct {
Key string `json:"key"`
EnvVar string `json:"envVar"`
Value interface{} `json:"value"`
}
type configResponse struct {
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config []configEntry `json:"config"`
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config map[string]interface{} `json:"config"`
}
func redactValue(key string, value string) string {
@@ -76,36 +69,32 @@ func redactValue(key string, value string) string {
return value
}
func flatten(ctx context.Context, entries *[]configEntry, prefix string, v reflect.Value) {
if v.Kind() == reflect.Struct && v.Type().PkgPath() != "time" {
t := v.Type()
for i := 0; i < v.NumField(); i++ {
if !t.Field(i).IsExported() {
continue
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
for key, value := range config {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case map[string]interface{}:
// Recursively process nested maps
applySensitiveFieldMasking(ctx, v, fullKey)
case string:
// Apply masking to string values
config[key] = redactValue(fullKey, v)
default:
// For other types (numbers, booleans, etc.), convert to string and check for masking
if str := fmt.Sprint(v); str != "" {
masked := redactValue(fullKey, str)
if masked != str {
// Only replace if masking was applied
config[key] = masked
}
}
flatten(ctx, entries, prefix+"."+t.Field(i).Name, v.Field(i))
}
return
}
key := strings.TrimPrefix(prefix, ".")
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
var val interface{}
switch v.Kind() {
case reflect.Map, reflect.Slice, reflect.Array:
b, err := json.Marshal(v.Interface())
if err != nil {
log.Error(ctx, "Error marshalling config value", "key", key, err)
val = "error marshalling value"
} else {
val = string(b)
}
default:
originalValue := fmt.Sprint(v.Interface())
val = redactValue(key, originalValue)
}
*entries = append(*entries, configEntry{Key: key, EnvVar: envVar, Value: val})
}
func getConfig(w http.ResponseWriter, r *http.Request) {
@@ -116,16 +105,32 @@ func getConfig(w http.ResponseWriter, r *http.Request) {
return
}
entries := make([]configEntry, 0)
v := reflect.ValueOf(*conf.Server)
t := reflect.TypeOf(*conf.Server)
for i := 0; i < v.NumField(); i++ {
fieldVal := v.Field(i)
fieldType := t.Field(i)
flatten(ctx, &entries, fieldType.Name, fieldVal)
// Marshal the actual configuration struct to preserve original field names
configBytes, err := json.Marshal(*conf.Server)
if err != nil {
log.Error(ctx, "Error marshaling config", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Unmarshal back to map to get the structure with proper field names
var configMap map[string]interface{}
err = json.Unmarshal(configBytes, &configMap)
if err != nil {
log.Error(ctx, "Error unmarshaling config to map", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Apply sensitive field masking
applySensitiveFieldMasking(ctx, configMap, "")
resp := configResponse{
ID: "config",
ConfigFile: conf.Server.ConfigFile,
Config: configMap,
}
resp := configResponse{ID: "config", ConfigFile: conf.Server.ConfigFile, Config: entries}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error(ctx, "Error encoding config response", err)
+44 -165
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -14,148 +13,44 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("config endpoint", func() {
var _ = Describe("getConfig", 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))
Context("when user is not admin", func() {
It("returns unauthorized", 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"
Context("when user is admin", func() {
It("returns config successfully", 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("LastFM.ApiKey", "b**************6"))
Expect(values).To(HaveKeyWithValue("Spotify.Secret", "v***************3"))
Expect(resp.ID).To(Equal("config"))
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
Expect(resp.Config).ToNot(BeEmpty())
})
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"
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 := httptest.NewRequest("GET", "/config", nil)
@@ -167,39 +62,26 @@ var _ = Describe("config endpoint", func() {
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
}
}
// Check LastFM.ApiKey (partially masked)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
Expect(values).To(HaveKeyWithValue("DevAutoCreateAdminPassword", "****"))
Expect(values).To(HaveKeyWithValue("Prometheus.Password", "****"))
})
// Check Spotify.Secret (partially masked)
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
It("does not redact non-sensitive values", func() {
conf.Server.MusicFolder = "/path/to/music"
conf.Server.Port = 4533
// Check PasswordEncryptionKey (fully masked)
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
// Check DevAutoCreateAdminPassword (fully masked)
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
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"))
// 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() {
@@ -215,16 +97,13 @@ var _ = Describe("config endpoint", func() {
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
}
}
// 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
Expect(values["LastFM.ApiKey"]).To(Equal(""))
Expect(values["PasswordEncryptionKey"]).To(Equal(""))
// Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
})
})
})
+1 -1
View File
@@ -23,7 +23,7 @@ import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead'
import { configToToml, separateAndSortConfigs } from '../utils/toml'
import { configToToml, separateAndSortConfigs } from './aboutUtils'
const useStyles = makeStyles((theme) => ({
configNameColumn: {
@@ -2,16 +2,72 @@
* TOML utility functions for configuration export
*/
/**
* Flattens nested configuration object and generates environment variable names
* @param {Object} config - The nested configuration object from the backend
* @param {string} prefix - The current prefix for nested keys
* @returns {Array} - Array of config objects with key, envVar, and value properties
*/
export const flattenConfig = (config, prefix = '') => {
const result = []
if (!config || typeof config !== 'object') {
return result
}
Object.keys(config).forEach((key) => {
const value = config[key]
const currentKey = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively flatten nested objects
result.push(...flattenConfig(value, currentKey))
} else {
// Generate environment variable name: ND_ + uppercase with dots replaced by underscores
const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_')
// Convert value to string for display
let displayValue = value
if (
Array.isArray(value) ||
(typeof value === 'object' && value !== null)
) {
displayValue = JSON.stringify(value)
} else {
displayValue = String(value)
}
result.push({
key: currentKey,
envVar: envVar,
value: displayValue,
})
}
})
return result
}
/**
* Separates and sorts configuration entries into regular and dev configs
* @param {Array} configEntries - Array of config objects with key and value
* @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
*/
export const separateAndSortConfigs = (configEntries) => {
const regularConfigs = []
const devConfigs = []
configEntries?.forEach((config) => {
// Handle both the old array format and new nested object format
let flattenedConfigs
if (Array.isArray(configEntries)) {
// Old format - already flattened
flattenedConfigs = configEntries
} else {
// New format - need to flatten
flattenedConfigs = flattenConfig(configEntries)
}
flattenedConfigs?.forEach((config) => {
// Skip configFile as it's displayed separately
if (config.key === 'ConfigFile') {
return
@@ -31,6 +87,30 @@ export const separateAndSortConfigs = (configEntries) => {
return { regularConfigs, devConfigs }
}
/**
* Escapes TOML keys that contain special characters
* @param {string} key - The key to potentially escape
* @returns {string} - The escaped key if needed, or the original key
*/
export const escapeTomlKey = (key) => {
// Convert to string first to handle null/undefined
const keyStr = String(key)
// Empty strings always need quotes
if (keyStr === '') {
return '""'
}
// TOML bare keys can only contain letters, numbers, underscores, and hyphens
// If the key contains other characters, it needs to be quoted
if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) {
return keyStr
}
// Escape quotes in the key and wrap in quotes
return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* Converts a value to proper TOML format
* @param {*} value - The value to format
@@ -61,10 +141,30 @@ export const formatTomlValue = (value) => {
return `"${str}"`
}
// Arrays/JSON objects
// Handle arrays and objects
if (str.startsWith('[') || str.startsWith('{')) {
try {
JSON.parse(str)
const parsed = JSON.parse(str)
// If it's an array, format as TOML array
if (Array.isArray(parsed)) {
const formattedItems = parsed.map((item) => {
if (typeof item === 'string') {
return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
} else if (typeof item === 'number' || typeof item === 'boolean') {
return String(item)
} else {
return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
})
if (formattedItems.length === 0) {
return '[ ]'
}
return `[ ${formattedItems.join(', ')} ]`
}
// For objects, keep the JSON string format with triple quotes
return `"""${str}"""`
} catch {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
@@ -111,9 +211,17 @@ export const buildTomlSections = (configs) => {
export const configToToml = (configData, translate = (key) => key) => {
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
const { regularConfigs, devConfigs } = separateAndSortConfigs(
configData.config,
)
// Handle both old array format (configData.config is array) and new nested format (configData.config is object)
let configs
if (Array.isArray(configData.config)) {
// Old format - already flattened
configs = configData.config
} else {
// New format - need to flatten
configs = flattenConfig(configData.config)
}
const { regularConfigs, devConfigs } = separateAndSortConfigs(configs)
// Process regular configs
const { sections: regularSections, rootKeys: regularRootKeys } =
@@ -149,7 +257,7 @@ export const configToToml = (configData, translate = (key) => key) => {
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
devSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
@@ -161,7 +269,7 @@ export const configToToml = (configData, translate = (key) => key) => {
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
regularSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
@@ -4,7 +4,9 @@ import {
buildTomlSections,
configToToml,
separateAndSortConfigs,
} from './toml'
flattenConfig,
escapeTomlKey,
} from './aboutUtils'
describe('formatTomlValue', () => {
it('handles null and undefined values', () => {
@@ -42,12 +44,25 @@ describe('formatTomlValue', () => {
})
it('handles JSON arrays and objects', () => {
expect(formatTomlValue('["item1", "item2"]')).toBe(
'"""["item1", "item2"]"""',
)
expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]')
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
})
it('formats different types of arrays correctly', () => {
// String array
expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe(
'[ "genre", "tcon", "©gen" ]',
)
// Mixed array with numbers and strings
expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]')
// Empty array
expect(formatTomlValue('[]')).toBe('[ ]')
// Array with special characters in strings
expect(
formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'),
).toBe('[ "item with spaces", "item\\"with\\"quotes" ]')
})
it('handles invalid JSON as regular strings', () => {
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
expect(formatTomlValue('{broken')).toBe('"{broken"')
@@ -302,12 +317,259 @@ describe('configToToml', () => {
expect(result).toContain('IntegerValue = 42')
expect(result).toContain('FloatValue = 3.14')
expect(result).toContain('DurationValue = "5s"')
expect(result).toContain('ArrayValue = """["item1", "item2"]"""')
expect(result).toContain('ArrayValue = [ "item1", "item2" ]')
})
it('handles nested config object format correctly', () => {
const configData = {
config: {
Address: '127.0.0.1',
Port: 4533,
EnableDownloads: true,
DevLogSourceLine: false,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
Scanner: {
Schedule: 'daily',
Enabled: true,
},
},
}
const result = configToToml(configData, mockTranslate)
// Should contain regular configs
expect(result).toContain('Address = "127.0.0.1"')
expect(result).toContain('Port = 4533')
expect(result).toContain('EnableDownloads = true')
// Should contain dev configs with header
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain('DevLogSourceLine = false')
// Should contain sections
expect(result).toContain('[LastFM]')
expect(result).toContain('Enabled = true')
expect(result).toContain('ApiKey = "secret123"')
expect(result).toContain('Language = "en"')
expect(result).toContain('[Scanner]')
expect(result).toContain('Schedule = "daily"')
})
it('handles mixed nested and flat structure', () => {
const configData = {
config: {
MusicFolder: '/music',
DevAutoLoginUsername: 'testuser',
Jukebox: {
Enabled: false,
AdminOnly: true,
},
},
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('MusicFolder = "/music"')
expect(result).toContain('DevAutoLoginUsername = "testuser"')
expect(result).toContain('[Jukebox]')
expect(result).toContain('Enabled = false')
expect(result).toContain('AdminOnly = true')
})
it('properly escapes keys with special characters in sections', () => {
const configData = {
config: [
{ key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' },
{ key: 'DevLogLevels.core/scanner', value: 'debug' },
{ key: 'DevLogLevels.regular_key', value: 'info' },
{ key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' },
],
}
const result = configToToml(configData, mockTranslate)
// Keys with forward slashes should be quoted
expect(result).toContain('"persistence/sql_base_repository" = "trace"')
expect(result).toContain('"core/scanner" = "debug"')
// Regular keys should not be quoted
expect(result).toContain('regular_key = "info"')
// Arrays should be formatted correctly
expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]')
// Should contain proper sections
expect(result).toContain('[DevLogLevels]')
expect(result).toContain('[Tags]')
})
})
describe('flattenConfig', () => {
it('flattens simple nested objects correctly', () => {
const config = {
Address: '0.0.0.0',
Port: 4533,
EnableDownloads: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Address',
envVar: 'ND_ADDRESS',
value: '0.0.0.0',
})
expect(result).toContainEqual({
key: 'Port',
envVar: 'ND_PORT',
value: '4533',
})
expect(result).toContainEqual({
key: 'EnableDownloads',
envVar: 'ND_ENABLEDOWNLOADS',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.Enabled',
envVar: 'ND_LASTFM_ENABLED',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.ApiKey',
envVar: 'ND_LASTFM_APIKEY',
value: 'secret123',
})
expect(result).toContainEqual({
key: 'LastFM.Language',
envVar: 'ND_LASTFM_LANGUAGE',
value: 'en',
})
})
it('handles deeply nested objects', () => {
const config = {
Scanner: {
Schedule: 'daily',
Options: {
ExtractorType: 'taglib',
ArtworkPriority: 'cover.jpg',
},
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Scanner.Schedule',
envVar: 'ND_SCANNER_SCHEDULE',
value: 'daily',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ExtractorType',
envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE',
value: 'taglib',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ArtworkPriority',
envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY',
value: 'cover.jpg',
})
})
it('handles arrays correctly', () => {
const config = {
DeviceList: ['device1', 'device2'],
Settings: {
EnabledFormats: ['mp3', 'flac', 'ogg'],
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'DeviceList',
envVar: 'ND_DEVICELIST',
value: '["device1","device2"]',
})
expect(result).toContainEqual({
key: 'Settings.EnabledFormats',
envVar: 'ND_SETTINGS_ENABLEDFORMATS',
value: '["mp3","flac","ogg"]',
})
})
it('handles null and undefined values', () => {
const config = {
NullValue: null,
UndefinedValue: undefined,
EmptyString: '',
ZeroValue: 0,
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'NullValue',
envVar: 'ND_NULLVALUE',
value: 'null',
})
expect(result).toContainEqual({
key: 'UndefinedValue',
envVar: 'ND_UNDEFINEDVALUE',
value: 'undefined',
})
expect(result).toContainEqual({
key: 'EmptyString',
envVar: 'ND_EMPTYSTRING',
value: '',
})
expect(result).toContainEqual({
key: 'ZeroValue',
envVar: 'ND_ZEROVALUE',
value: '0',
})
})
it('handles empty object', () => {
const result = flattenConfig({})
expect(result).toEqual([])
})
it('handles null/undefined input', () => {
expect(flattenConfig(null)).toEqual([])
expect(flattenConfig(undefined)).toEqual([])
})
it('handles non-object input', () => {
expect(flattenConfig('string')).toEqual([])
expect(flattenConfig(123)).toEqual([])
expect(flattenConfig(true)).toEqual([])
})
})
describe('separateAndSortConfigs', () => {
it('separates regular and dev configs correctly', () => {
it('separates regular and dev configs correctly with array input', () => {
const configs = [
{ key: 'RegularKey1', value: 'value1' },
{ key: 'DevTestFlag', value: 'true' },
@@ -328,6 +590,37 @@ describe('separateAndSortConfigs', () => {
])
})
it('separates regular and dev configs correctly with nested object input', () => {
const config = {
Address: '127.0.0.1',
Port: 4533,
DevAutoLoginUsername: 'testuser',
DevLogSourceLine: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
},
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' },
{ key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' },
{ key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' },
{ key: 'Port', envVar: 'ND_PORT', value: '4533' },
])
expect(result.devConfigs).toEqual([
{
key: 'DevAutoLoginUsername',
envVar: 'ND_DEVAUTOLOGINUSERNAME',
value: 'testuser',
},
{ key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' },
])
})
it('skips ConfigFile entries', () => {
const configs = [
{ key: 'ConfigFile', value: '/path/to/config.toml' },
@@ -343,6 +636,23 @@ describe('separateAndSortConfigs', () => {
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
})
it('skips ConfigFile entries with nested object input', () => {
const config = {
ConfigFile: '/path/to/config.toml',
RegularKey: 'value',
DevFlag: true,
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' },
])
})
it('handles empty input', () => {
const result = separateAndSortConfigs([])
@@ -376,3 +686,52 @@ describe('separateAndSortConfigs', () => {
expect(result.devConfigs[1].key).toBe('DevZ')
})
})
describe('escapeTomlKey', () => {
it('does not escape valid bare keys', () => {
expect(escapeTomlKey('RegularKey')).toBe('RegularKey')
expect(escapeTomlKey('regular_key')).toBe('regular_key')
expect(escapeTomlKey('regular-key')).toBe('regular-key')
expect(escapeTomlKey('key123')).toBe('key123')
expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores')
expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens')
})
it('escapes keys with special characters', () => {
// Keys with forward slashes (like DevLogLevels keys)
expect(escapeTomlKey('persistence/sql_base_repository')).toBe(
'"persistence/sql_base_repository"',
)
expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"')
// Keys with dots
expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"')
// Keys with spaces
expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"')
// Keys with other special characters
expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"')
expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"')
})
it('escapes quotes in keys', () => {
expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"')
expect(escapeTomlKey('key with "quotes" inside')).toBe(
'"key with \\"quotes\\" inside"',
)
})
it('escapes backslashes in keys', () => {
expect(escapeTomlKey('key\\with\\backslashes')).toBe(
'"key\\\\with\\\\backslashes"',
)
expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"')
})
it('handles empty and null keys', () => {
expect(escapeTomlKey('')).toBe('""')
expect(escapeTomlKey(null)).toBe('null')
expect(escapeTomlKey(undefined)).toBe('undefined')
})
})