mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
Duplicated helpers cmd/rip had its own copies of six helpers that already existed in internal/jsonutil: asString (StringFromAny), searchInt (IntFromAny), nestedSearchInt and nestedSearchString (NestedAny/NestedString), firstNonEmpty (FirstNonEmpty) and firstPositiveInt. firstPositiveInt was duplicated a third and fourth time in provider/yandex and provider/beatport, so it moves into jsonutil alongside the rest. jsonutil is back to 100% coverage. searchBool is deliberately left alone, with a comment saying why: it accepts only a real bool, and switching it to jsonutil.BoolFromAny would start coercing strings and numbers, changing how search results get labelled. config auto-upgrade is no longer destructive UpgradeOutdated re-serializes the whole config from the parsed struct, so any key this version does not know about is dropped, and it runs automatically from run() without prompting. It now copies the original to <path>.bak-<unixtime> (0600, since it holds the same credentials) and validates the result before writing. It returns the backup path so the CLI can tell the user where it went. The comment states plainly that this is a version stamp rather than a migration framework. cmd/rip tests The command bodies are testable now that they are functions returning exit codes rather than arms of a switch that called os.Exit. Covers the usage errors for every command, the quality floor on `rip id`, config path output, the --version flag, and that applyQualityOverride reaches all six sources and clamps only the two with a floor. cmd/rip coverage 17% -> 26%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442 lines
13 KiB
Go
442 lines
13 KiB
Go
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, backup, err := UpgradeOutdated(path)
|
|
if err != nil {
|
|
t.Fatalf("UpgradeOutdated() error = %v", err)
|
|
}
|
|
if resolved != path {
|
|
t.Fatalf("resolved path = %q, want %q", resolved, path)
|
|
}
|
|
|
|
// The upgrade re-serializes the whole file and runs without prompting, so
|
|
// it must leave the original behind.
|
|
if backup == "" {
|
|
t.Fatal("UpgradeOutdated() returned no backup path")
|
|
}
|
|
backupRaw, err := os.ReadFile(backup)
|
|
if err != nil {
|
|
t.Fatalf("backup not written: %v", err)
|
|
}
|
|
var backedUp ConfigData
|
|
if err := toml.Unmarshal(backupRaw, &backedUp); err != nil {
|
|
t.Fatalf("backup is not valid toml: %v", err)
|
|
}
|
|
if backedUp.Misc.Version != "1.0.0" {
|
|
t.Errorf("backup version = %q, want the pre-upgrade 1.0.0", backedUp.Misc.Version)
|
|
}
|
|
if info, err := os.Stat(backup); err != nil {
|
|
t.Fatal(err)
|
|
} else if perm := info.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("backup permissions = %04o, want 0600 (it holds the same credentials)", perm)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|