diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index b945778..6cdbdeb 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -481,9 +481,18 @@ func main() { config := loadConfig(c) ds := initDataStore(config.dataDir) + // Detect a genuinely fresh data dir by the ABSENCE of settings.json, + // not by an empty server_url. A hand-authored settings.json (e.g. one + // that only sets trust_forwarded_headers and leaves server_url to the + // --server-url flag) exists but has no server_url; keying the "first + // run" default-write off server_url would treat it as fresh and + // clobber the operator's file, dropping fields createDefaultSettings + // doesn't know about. + settingsExisted := settingsFileExists(config.dataDir) + persisted := applyPersistedSettings(ds, &config) - if persisted.ServerURL == "" { + if !settingsExisted { log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir)) log.Printf("Data directory %s looks empty (first run). If you did NOT expect this "+ "(e.g. after recreating a Docker container), your previous settings, datastore and "+ @@ -913,6 +922,20 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) return domains } +// settingsFileExists reports whether a settings.json is already present in the +// data dir. It's the first-run discriminator: an existing file (even an +// incomplete, hand-authored one) must never be overwritten by the default +// seed, while a truly empty data dir gets defaults plus the lost-volume notice. +func settingsFileExists(dataDir string) bool { + if dataDir == "" { + return false + } + + _, err := os.Stat(filepath.Join(dataDir, "settings.json")) + + return err == nil +} + func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings { persisted, err := ds.GetSettings() if err != nil { diff --git a/cmd/soundtouch-service/main_test.go b/cmd/soundtouch-service/main_test.go index 3f34cc2..490ffcd 100644 --- a/cmd/soundtouch-service/main_test.go +++ b/cmd/soundtouch-service/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "path/filepath" "testing" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" @@ -187,3 +188,83 @@ func contains(haystack []string, needle string) bool { return false } + +func TestSettingsFileExists(t *testing.T) { + dir := t.TempDir() + + if settingsFileExists(dir) { + t.Fatal("expected false for a dir without settings.json") + } + + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("write settings.json: %v", err) + } + + if !settingsFileExists(dir) { + t.Fatal("expected true once settings.json is present") + } + + if settingsFileExists("") { + t.Fatal("expected false for an empty data dir") + } +} + +// applyFirstRunSeed mirrors the startup gate in the CLI Action: a default +// settings.json is written only when none exists yet, so a hand-authored file +// is never clobbered. +func applyFirstRunSeed(ds *datastore.DataStore, config *serviceConfig) { + existed := settingsFileExists(config.dataDir) + + applyPersistedSettings(ds, config) + + if !existed { + createDefaultSettings(ds, *config) + } +} + +func TestFirstRunSeed_PreservesHandAuthoredSettings(t *testing.T) { + dir := t.TempDir() + + // Operator pre-seeds proxy trust but leaves server_url to the --server-url + // flag. Before the fix this was treated as "first run" and overwritten. + if err := os.WriteFile(filepath.Join(dir, "settings.json"), + []byte(`{"trust_forwarded_headers":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`), 0o644); err != nil { + t.Fatalf("write settings.json: %v", err) + } + + ds := datastore.NewDataStore(dir) + config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"} + + applyFirstRunSeed(ds, config) + + got, err := ds.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + + if !got.TrustForwardedHeaders { + t.Error("trust_forwarded_headers was clobbered on startup") + } + + if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" { + t.Errorf("trusted_proxy_cidrs was clobbered, got %v", got.TrustedProxyCIDRs) + } +} + +func TestFirstRunSeed_WritesDefaultsWhenAbsent(t *testing.T) { + dir := t.TempDir() + + ds := datastore.NewDataStore(dir) + config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"} + + applyFirstRunSeed(ds, config) + + got, err := ds.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + + if got.ServerURL != "http://192.0.2.1:8000" { + t.Errorf("expected defaults to be written with server_url, got %q", got.ServerURL) + } +}