diff --git a/conf/configuration.go b/conf/configuration.go index 64a4e6a75..a7dc342b0 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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 { diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 500e9098f..d708d72f9 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -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) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index eef8a81a2..52baef83a 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -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("")) }) }) }) diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx index e0d796691..cb605cde1 100644 --- a/ui/src/dialogs/AboutDialog.jsx +++ b/ui/src/dialogs/AboutDialog.jsx @@ -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: { diff --git a/ui/src/utils/toml.js b/ui/src/dialogs/aboutUtils.js similarity index 52% rename from ui/src/utils/toml.js rename to ui/src/dialogs/aboutUtils.js index 1bb0aeea2..7c92692de 100644 --- a/ui/src/utils/toml.js +++ b/ui/src/dialogs/aboutUtils.js @@ -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' }) diff --git a/ui/src/utils/toml.test.js b/ui/src/dialogs/aboutUtils.test.js similarity index 52% rename from ui/src/utils/toml.test.js rename to ui/src/dialogs/aboutUtils.test.js index 2f1ab1afc..4632c5aed 100644 --- a/ui/src/utils/toml.test.js +++ b/ui/src/dialogs/aboutUtils.test.js @@ -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') + }) +})