package config import ( "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "github.com/pelletier/go-toml/v2" ) func TestDefaultConfigData(t *testing.T) { data := DefaultConfigData() if data.Misc.Version != CurrentConfigVersion { t.Fatalf("version = %q, want %q", data.Misc.Version, CurrentConfigVersion) } if data.Downloads.Folder == "" { t.Fatalf("downloads folder should not be empty") } if data.Database.DownloadsPath == "" || data.Database.FailedDownloadsPath == "" { t.Fatalf("database paths should not be empty") } } func TestLoadCreatesDefaultConfigWhenMissing(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Path != path { t.Fatalf("path = %q, want %q", cfg.Path, path) } if _, err = os.Stat(path); err != nil { t.Fatalf("expected created config file: %v", err) } } func TestLoadOutdatedConfig(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Misc.Version = "1.0.0" if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } _, err := Load(path) if !errors.Is(err, ErrOutdatedConfig) { t.Fatalf("Load() error = %v, want ErrOutdatedConfig", err) } } func TestUpgradeOutdatedConfig(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Misc.Version = "1.0.0" data.Downloads.Folder = filepath.Join(tmpDir, "Music") if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } resolved, err := UpgradeOutdated(path) if err != nil { t.Fatalf("UpgradeOutdated() error = %v", err) } if resolved != path { t.Fatalf("resolved path = %q, want %q", resolved, path) } cfg, err := Load(path) if err != nil { t.Fatalf("Load() after upgrade error = %v", err) } if cfg.File.Misc.Version != CurrentConfigVersion { t.Fatalf("version = %q, want %q", cfg.File.Misc.Version, CurrentConfigVersion) } if cfg.File.Downloads.Folder != data.Downloads.Folder { t.Fatalf("downloads folder changed unexpectedly") } } func TestSessionCloneDoesNotAliasSlices(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Metadata.Exclude = []string{"lyrics"} data.Qobuz.Secrets = []string{"s1"} if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } cfg.Session.Metadata.Exclude[0] = "comment" cfg.Session.Qobuz.Secrets[0] = "s2" if cfg.File.Metadata.Exclude[0] != "lyrics" { t.Fatalf("file metadata exclude unexpectedly mutated") } if cfg.File.Qobuz.Secrets[0] != "s1" { t.Fatalf("file qobuz secrets unexpectedly mutated") } } func TestDefaultConfigPassesValidation(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) if err := Validate(&d); err != nil { t.Fatalf("shipped defaults fail validation: %v", err) } } // TestExampleConfigIsValid guards the file users are told to copy. func TestExampleConfigIsValid(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example")) if err != nil { t.Skipf("example config not found: %v", err) } data := DefaultConfigData() if err := toml.Unmarshal(raw, &data); err != nil { t.Fatalf("config.toml.example does not parse: %v", err) } applyRuntimeDefaults(&data) if err := Validate(&data); err != nil { t.Fatalf("config.toml.example fails validation: %v", err) } if data.Misc.Version != CurrentConfigVersion { t.Errorf("config.toml.example version = %q, want %q", data.Misc.Version, CurrentConfigVersion) } } func TestValidateRejectsBadValues(t *testing.T) { tests := []struct { name string mutate func(*ConfigData) want string }{ {"qobuz quality 0", func(d *ConfigData) { d.Qobuz.Quality = 0 }, "qobuz.quality"}, {"qobuz quality 5", func(d *ConfigData) { d.Qobuz.Quality = 5 }, "qobuz.quality"}, {"beatport quality 0", func(d *ConfigData) { d.Beatport.Quality = 0 }, "beatport.quality"}, {"tidal quality 5", func(d *ConfigData) { d.Tidal.Quality = 5 }, "tidal.quality"}, {"negative quality", func(d *ConfigData) { d.Tidal.Quality = -1 }, "tidal.quality"}, {"max_connections 0", func(d *ConfigData) { d.Downloads.MaxConnections = 0 }, "max_connections"}, {"max_connections negative", func(d *ConfigData) { d.Downloads.MaxConnections = -5 }, "max_connections"}, {"requests_per_minute 0", func(d *ConfigData) { d.Downloads.RequestsPerMinute = 0 }, "requests_per_minute"}, {"truncate_to 0", func(d *ConfigData) { d.Filepaths.TruncateTo = 0 }, "truncate_to"}, {"bogus codec", func(d *ConfigData) { d.Conversion.Enabled = true d.Conversion.Codec = "bogus" }, "conversion.codec"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) tc.mutate(&d) err := Validate(&d) if err == nil { t.Fatalf("Validate() = nil, want an error mentioning %q", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("Validate() = %q, want it to mention %q", err, tc.want) } }) } } // TestValidateAcceptsDocumentedQualityValues pins the ranges documented in // config.toml.example. An earlier draft capped deezer at 2 and soundcloud at 1, // which would have made a config the example itself documents fail to load. func TestValidateAcceptsDocumentedQualityValues(t *testing.T) { for q := 0; q <= 4; q++ { d := DefaultConfigData() applyRuntimeDefaults(&d) d.Deezer.Quality = q d.Soundcloud.Quality = q d.Tidal.Quality = q d.Yandex.Quality = q if err := Validate(&d); err != nil { t.Errorf("quality %d rejected: %v", q, err) } } // Qobuz and Beatport have no tier 0. for q := 1; q <= 4; q++ { d := DefaultConfigData() applyRuntimeDefaults(&d) d.Qobuz.Quality = q d.Beatport.Quality = q if err := Validate(&d); err != nil { t.Errorf("qobuz/beatport quality %d rejected: %v", q, err) } } } func TestValidateAcceptsValidValues(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) // Negative requests_per_minute disables throttling, which is legitimate. d.Downloads.RequestsPerMinute = -1 d.Soundcloud.Quality = 1 d.Deezer.Quality = 4 d.Qobuz.Quality = 4 if err := Validate(&d); err != nil { t.Fatalf("Validate() = %v, want nil", err) } } func TestValidateNormalizesCodec(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) d.Conversion.Enabled = true d.Conversion.Codec = " ogg " if err := Validate(&d); err != nil { t.Fatalf("Validate() = %v", err) } if d.Conversion.Codec != "VORBIS" { t.Fatalf("codec = %q, want VORBIS", d.Conversion.Codec) } } func TestNormalizeCodec(t *testing.T) { for in, want := range map[string]string{ "flac": "FLAC", "FLAC": "FLAC", " mp3 ": "MP3", "ogg": "VORBIS", "vorbis": "VORBIS", "alac": "ALAC", "aac": "AAC", } { got, err := NormalizeCodec(in) if err != nil { t.Errorf("NormalizeCodec(%q) err = %v", in, err) continue } if got != want { t.Errorf("NormalizeCodec(%q) = %q, want %q", in, got, want) } } if _, err := NormalizeCodec("wav"); err == nil { t.Error("NormalizeCodec(wav) should fail") } } func TestCloneConfigDataDoesNotShareSlices(t *testing.T) { a := DefaultConfigData() a.Qobuz.Secrets = []string{"s1", "s2"} a.Metadata.Exclude = []string{"e1"} b := CloneConfigData(a) b.Qobuz.Secrets[0] = "mutated" b.Metadata.Exclude[0] = "mutated" if a.Qobuz.Secrets[0] != "s1" { t.Error("Qobuz.Secrets is shared between copies") } if a.Metadata.Exclude[0] != "e1" { t.Error("Metadata.Exclude is shared between copies") } } func TestSaveFileUsesOwnerOnlyPermissions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") cfg := &Config{Path: path, File: DefaultConfigData()} if err := cfg.SaveFile(); err != nil { t.Fatalf("SaveFile() = %v", err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } // The file holds plaintext passwords, ARL cookies and OAuth tokens. if perm := info.Mode().Perm(); perm != 0o600 { t.Fatalf("config permissions = %04o, want 0600", perm) } } func TestSaveFileTightensExistingWorldReadableFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte("version = \"x\"\n"), 0o644); err != nil { t.Fatal(err) } cfg := &Config{Path: path, File: DefaultConfigData()} if err := cfg.SaveFile(); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Fatalf("permissions = %04o, want 0600 after rewrite", perm) } } func TestUpdateTokensIsSafeWithoutAPath(t *testing.T) { cfg := &Config{File: DefaultConfigData(), Session: DefaultConfigData()} err := cfg.UpdateTokens(func(file, session *ConfigData) { file.Deezer.RefreshToken = "rt" session.Deezer.RefreshToken = "rt" }) if err != nil { t.Fatalf("UpdateTokens() = %v", err) } // The in-memory update must still happen even with nothing to write to. if cfg.File.Deezer.RefreshToken != "rt" || cfg.Session.Deezer.RefreshToken != "rt" { t.Fatal("UpdateTokens did not apply the mutation") } } func TestUpdateTokensPersistsAndIsConcurrencySafe(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") cfg := &Config{Path: path, File: DefaultConfigData(), Session: DefaultConfigData()} var wg sync.WaitGroup for i := 0; i < 16; i++ { wg.Add(1) go func(n int) { defer wg.Done() token := fmt.Sprintf("token-%d", n) if err := cfg.UpdateTokens(func(file, session *ConfigData) { file.Tidal.AccessToken = token session.Tidal.AccessToken = token }); err != nil { t.Errorf("UpdateTokens: %v", err) } }(i) } wg.Wait() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var round ConfigData if err := toml.Unmarshal(raw, &round); err != nil { t.Fatalf("concurrent writes produced an unparseable config: %v", err) } if round.Tidal.AccessToken == "" { t.Error("no token persisted") } } // TestVendorDefaultsAreApplied guards the Phase-3 move of vendor credentials // into config: an empty value must resolve to the built-in constant, or the // providers silently authenticate and sign with empty keys. func TestVendorDefaultsAreApplied(t *testing.T) { d := DefaultConfigData() checks := map[string]string{ "qobuz.mobile_app_id": d.Qobuz.MobileAppID, "qobuz.mobile_app_secret": d.Qobuz.MobileAppSecret, "tidal.app_client_id": d.Tidal.AppClientID, "tidal.app_client_secret": d.Tidal.AppClientSecret, "deezer.api_key": d.Deezer.APIKey, "deezer.gateway_key": d.Deezer.GatewayKey, "deezer.blowfish_secret": d.Deezer.BlowfishSecret, "yandex.sign_key": d.Yandex.SignKey, "yandex.legacy_sign_salt": d.Yandex.LegacyMP3SignSalt, "beatport.app_client_id": d.Beatport.AppClientID, } for name, got := range checks { if got == "" { t.Errorf("%s is empty in DefaultConfigData()", name) } } // An explicitly empty config must be backfilled too, since that is what a // user config written before these keys existed looks like. var blank ConfigData applyVendorDefaults(&blank) if blank.Qobuz.MobileAppSecret != DefaultQobuzMobileAppSecret { t.Error("applyVendorDefaults did not backfill qobuz.mobile_app_secret") } if blank.Deezer.BlowfishSecret != DefaultDeezerBlowfishSecret { t.Error("applyVendorDefaults did not backfill deezer.blowfish_secret") } } func TestVendorDefaultsAreNotOverriddenWhenSet(t *testing.T) { d := ConfigData{} d.Tidal.AppClientID = "mine" d.Deezer.BlowfishSecret = "0123456789abcdef" applyVendorDefaults(&d) if d.Tidal.AppClientID != "mine" { t.Errorf("user value overwritten: %q", d.Tidal.AppClientID) } if d.Deezer.BlowfishSecret != "0123456789abcdef" { t.Errorf("user value overwritten: %q", d.Deezer.BlowfishSecret) } } // TestDeezerBlowfishSecretLength pins the length the key derivation depends // on: it XORs 16 bytes, so a different length would panic or silently derive a // wrong key. func TestDeezerBlowfishSecretLength(t *testing.T) { if got := len(DefaultDeezerBlowfishSecret); got != 16 { t.Fatalf("DefaultDeezerBlowfishSecret is %d bytes, want 16", got) } } // TestQobuzMobileAppSecretIsHex pins the format the HKDF step requires. func TestQobuzMobileAppSecretIsHex(t *testing.T) { if _, err := hex.DecodeString(DefaultQobuzMobileAppSecret); err != nil { t.Fatalf("DefaultQobuzMobileAppSecret is not hex: %v", err) } }