fix(service): detect first run by settings.json absence, not empty server_url

Startup treated an empty server_url as "first run" and wrote a fresh
default settings.json via createDefaultSettings, which builds the struct
from CLI flags and does not merge the existing file. A hand-authored
settings.json that sets, say, trust_forwarded_headers but leaves
server_url to the --server-url flag has no server_url, so it was
silently clobbered on first start (losing the operator's keys).

Gate the default-seed (and the lost-volume "first run" notice) on the
ABSENCE of settings.json instead. An existing file is now always
respected; a genuinely empty data dir still gets defaults and the
notice. This also fixes a latent loop where a never-set server_url made
every start look like a first run.

Adds regression tests: settingsFileExists, plus first-run seed both
preserving a hand-authored file and writing defaults when absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-28 16:06:13 +02:00
co-authored by Claude Opus 4.8
parent d31710bd8e
commit 1cac9989be
2 changed files with 105 additions and 1 deletions
+24 -1
View File
@@ -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 {
+81
View File
@@ -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)
}
}