package installer import ( "errors" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // recordingCopyFS returns a MockFileSystem whose CopyFile records the call and // delegates to the real OS-backed implementation, so tests can run against // real temp files while still proving the copy went through the interface. func recordingCopyFS() *MockFileSystem { mfs := NewMockFileSystem() mfs.ReadFileFunc = os.ReadFile mfs.CopyFileFunc = func(src, dst string, perm os.FileMode) error { mfs.CopiedFiles = append(mfs.CopiedFiles, CopyCall{Src: src, Dst: dst}) return OSFileSystem{}.CopyFile(src, dst, perm) } return mfs } // newBufferedRunner returns a CommandRunner with a buffered output channel. func newBufferedRunner() (*CommandRunner, chan string) { out := make(chan string, 64) return NewCommandRunner(out, nil), out } // assertOutputContains asserts that some buffered output message contains substr. func assertOutputContains(t *testing.T, messages []string, substr string) { t.Helper() for _, m := range messages { if strings.Contains(m, substr) { return } } t.Errorf("no output message contains %q; got %#v", substr, messages) } // isolateHome redirects native home/config variables so config-path // resolution (mpv.conf, conf_backups) stays inside the test sandbox. func isolateHome(t *testing.T) string { t.Helper() home := t.TempDir() for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"} { t.Setenv(key, home) } t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) return home } // isolateConfig additionally reloads the global config from the sandbox and // restores it afterwards (the cleanup is registered before the env overrides // so it runs after they are restored). func isolateConfig(t *testing.T) { t.Helper() t.Cleanup(func() { _, _ = config.Reload() }) isolateHome(t) _, err := config.Reload() require.NoError(t, err) } // validBackupPath returns a path inside the system backup directory whose name // satisfies config.ValidateBackupPath. func validBackupPath(t *testing.T) string { t.Helper() backupDir, err := GetConfigBackupDir() require.NoError(t, err) require.NoError(t, os.MkdirAll(backupDir, constants.DirPermission)) return filepath.Join(backupDir, time.Now().Format(constants.BackupTimestampFullFormat)+constants.BackupDirFilePrefix) } func TestCreateFullBackup_RoutesCopyThroughFileSystem(t *testing.T) { isolateHome(t) configDir, err := GetMPVConfigDir() require.NoError(t, err) require.NoError(t, os.MkdirAll(configDir, constants.DirPermission)) configPath := filepath.Join(configDir, constants.MPVConfigFileName) require.NoError(t, os.WriteFile(configPath, []byte("user config"), constants.FilePermission)) t.Run("success copies via the injected FileSystem", func(t *testing.T) { mfs := recordingCopyFS() cr, out := newBufferedRunner() backupPath, err := CreateFullBackup(mfs, configPath, cr) require.NoError(t, err) assert.Contains(t, backupPath, constants.ConfigBackupsDir) assert.True(t, strings.HasSuffix(backupPath, constants.BackupDirFilePrefix)) data, err := os.ReadFile(backupPath) require.NoError(t, err) assert.Equal(t, "user config", string(data)) assert.Empty(t, mfs.CopiedFiles, "backup publication should use a durable unique write, not a direct copy") outputs := drainOutput(out) assert.Contains(t, outputs, "Backup created successfully!") }) t.Run("copy failure propagates", func(t *testing.T) { mfs := NewMockFileSystem() mfs.ReadFileFunc = func(path string) ([]byte, error) { return nil, errors.New("copy boom") } cr, _ := newBufferedRunner() backupPath, err := CreateFullBackup(mfs, configPath, cr) require.Error(t, err) assert.ErrorContains(t, err, "copy boom") assert.Empty(t, backupPath) }) t.Run("backups are unique and accepted by restore validation", func(t *testing.T) { mfs := recordingCopyFS() first, err := CreateFullBackup(mfs, configPath, nil) require.NoError(t, err) second, err := CreateFullBackup(mfs, configPath, nil) require.NoError(t, err) assert.NotEqual(t, first, second) assert.NoError(t, config.ValidateBackupPath(first)) assert.NoError(t, config.ValidateBackupPath(second)) }) t.Run("automatic backup retention is bounded", func(t *testing.T) { mfs := recordingCopyFS() for index := 0; index < maxConfigBackups+4; index++ { _, err := CreateFullBackup(mfs, configPath, nil) require.NoError(t, err) } backupDir, err := GetConfigBackupDir() require.NoError(t, err) entries, err := os.ReadDir(backupDir) require.NoError(t, err) count := 0 for _, entry := range entries { if strings.HasSuffix(entry.Name(), constants.BackupDirFilePrefix) { count++ } } assert.Equal(t, maxConfigBackups, count) }) } func TestRestoreBackup_RoutesCopyThroughFileSystem(t *testing.T) { isolateHome(t) configDir, err := GetMPVConfigDir() require.NoError(t, err) require.NoError(t, os.MkdirAll(configDir, constants.DirPermission)) configPath := filepath.Join(configDir, constants.MPVConfigFileName) t.Run("restore without existing config", func(t *testing.T) { mfs := recordingCopyFS() backupPath := validBackupPath(t) require.NoError(t, os.WriteFile(backupPath, []byte("restored config"), constants.FilePermission)) cr, _ := newBufferedRunner() require.NoError(t, RestoreBackup(mfs, backupPath, configPath, cr)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "restored config", string(data)) assert.Empty(t, mfs.CopiedFiles, "restore reads through a descriptor-relative backup root") }) t.Run("restore renames an existing config aside first", func(t *testing.T) { mfs := recordingCopyFS() backupPath := validBackupPath(t) require.NoError(t, os.WriteFile(backupPath, []byte("from backup"), constants.FilePermission)) require.NoError(t, os.WriteFile(configPath, []byte("current config"), constants.FilePermission)) cr, out := newBufferedRunner() require.NoError(t, RestoreBackup(mfs, backupPath, configPath, cr)) data, err := os.ReadFile(configPath) require.NoError(t, err) assert.Equal(t, "from backup", string(data)) // The pre-existing config was copied to a unique durable recovery file; // the live pathname was never renamed away. backupDir, err := GetConfigBackupDir() require.NoError(t, err) entries, err := os.ReadDir(backupDir) require.NoError(t, err) foundRecovery := false for _, entry := range entries { path := filepath.Join(backupDir, entry.Name()) if path == backupPath { continue } aside, readErr := os.ReadFile(path) if readErr == nil && string(aside) == "current config" { foundRecovery = true break } } assert.True(t, foundRecovery, "pre-restore recovery snapshot was not retained") assertOutputContains(t, drainOutput(out), "Current config recovery snapshot:") }) t.Run("missing backup fails", func(t *testing.T) { mfs := recordingCopyFS() cr, _ := newBufferedRunner() // An hour-old timestamp keeps the name valid but guaranteed absent // (other subtests create backups stamped with the current second). backupDir, err := GetConfigBackupDir() require.NoError(t, err) missing := filepath.Join(backupDir, time.Now().Add(-time.Hour).Format(constants.BackupTimestampFullFormat)+constants.BackupDirFilePrefix) err = RestoreBackup(mfs, missing, configPath, cr) require.Error(t, err) assert.Contains(t, err.Error(), "backup file not found") assert.Empty(t, mfs.CopiedFiles) }) t.Run("invalid backup path fails validation", func(t *testing.T) { mfs := recordingCopyFS() cr, _ := newBufferedRunner() err := RestoreBackup(mfs, filepath.Join(t.TempDir(), "mpv.conf"), configPath, cr) require.Error(t, err) assert.Contains(t, err.Error(), "invalid backup") assert.Empty(t, mfs.CopiedFiles) }) t.Run("recovery backup failure propagates", func(t *testing.T) { mfs := NewMockFileSystem() mfs.ReadFileFunc = func(string) ([]byte, error) { return nil, errors.New("recovery boom") } backupPath := validBackupPath(t) require.NoError(t, os.WriteFile(backupPath, []byte("data"), constants.FilePermission)) require.NoError(t, os.WriteFile(configPath, []byte("live stays"), constants.FilePermission)) cr, _ := newBufferedRunner() err := RestoreBackup(mfs, backupPath, configPath, cr) require.Error(t, err) assert.ErrorContains(t, err, "recovery boom") live, readErr := os.ReadFile(configPath) require.NoError(t, readErr) assert.Equal(t, "live stays", string(live)) }) } func TestCopyUIPath(t *testing.T) { t.Run("copies a regular file preserving permissions", func(t *testing.T) { mfs := recordingCopyFS() src := filepath.Join(t.TempDir(), "modernz.lua") require.NoError(t, os.WriteFile(src, []byte("-- lua"), 0600)) dst := filepath.Join(t.TempDir(), "nested", "modernz.lua") require.NoError(t, copyInstallerPath(mfs, src, dst, false)) data, err := os.ReadFile(dst) require.NoError(t, err) assert.Equal(t, "-- lua", string(data)) info, err := os.Stat(dst) require.NoError(t, err) sourceInfo, err := os.Stat(src) require.NoError(t, err) assert.Equal(t, sourceInfo.Mode().Perm(), info.Mode().Perm()) assert.Contains(t, mfs.CopiedFiles, CopyCall{Src: src, Dst: dst}) }) t.Run("copies a directory tree", func(t *testing.T) { mfs := recordingCopyFS() src := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(src, "sub"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(src, "a.txt"), []byte("a"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(src, "sub", "b.txt"), []byte("b"), 0640)) dst := filepath.Join(t.TempDir(), "out") require.NoError(t, copyInstallerPath(mfs, src, dst, false)) for name, want := range map[string]string{"a.txt": "a", filepath.Join("sub", "b.txt"): "b"} { data, err := os.ReadFile(filepath.Join(dst, name)) require.NoError(t, err) assert.Equal(t, want, string(data)) } assert.Len(t, mfs.CopiedFiles, 2) }) t.Run("rejects a symlink source", func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("creating symlinks may require elevated privileges on Windows") } mfs := recordingCopyFS() target := filepath.Join(t.TempDir(), "real.txt") require.NoError(t, os.WriteFile(target, []byte("x"), 0644)) link := filepath.Join(t.TempDir(), "link.txt") require.NoError(t, os.Symlink(target, link)) err := copyInstallerPath(mfs, link, filepath.Join(t.TempDir(), "out"), false) require.Error(t, err) assert.Contains(t, err.Error(), "refusing to replace unsupported UI path") assert.Empty(t, mfs.CopiedFiles) }) t.Run("rejects a symlink inside a walked directory", func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("creating symlinks may require elevated privileges on Windows") } mfs := recordingCopyFS() src := t.TempDir() outside := filepath.Join(t.TempDir(), "outside.txt") require.NoError(t, os.WriteFile(outside, []byte("x"), 0644)) require.NoError(t, os.Symlink(outside, filepath.Join(src, "link.txt"))) err := copyInstallerPath(mfs, src, filepath.Join(t.TempDir(), "out"), false) require.Error(t, err) assert.Contains(t, err.Error(), "refusing to replace unsupported UI path") }) t.Run("missing source propagates the stat error", func(t *testing.T) { mfs := recordingCopyFS() err := copyInstallerPath(mfs, filepath.Join(t.TempDir(), "nope"), filepath.Join(t.TempDir(), "out"), false) require.Error(t, err) assert.Empty(t, mfs.CopiedFiles) }) } func TestInstallUISafely_NoneRemovesUIAndRestoresDefaultOSC(t *testing.T) { isolateConfig(t) configDir := t.TempDir() managedFiles := map[string]string{ filepath.Join(constants.ScriptsDir, "uosc", "main.lua"): "-- uosc module", filepath.Join(constants.FontsDir, constants.UOSCIconsFontFile): "icons", filepath.Join(constants.FontsDir, constants.UOSCTexturesFontFile): "textures", filepath.Join(constants.ScriptsDir, "uosc.lua"): "-- uosc", filepath.Join(constants.ScriptsDir, "modernz.lua"): "-- modernz", filepath.Join(constants.ScriptsDir, "thumbfast.lua"): "-- thumbfast", filepath.Join(constants.ScriptOptsDir, "uosc.conf"): "uosc conf", filepath.Join(constants.ScriptOptsDir, "modernz.conf"): "modernz conf", filepath.Join(constants.FontsDir, constants.ModernZFontFile): "font", filepath.Join(constants.FontsDir, "material-design-icons.ttf"): "legacy font", filepath.Join(constants.FontsDir, "fluent-system-icons.ttf"): "legacy font", constants.MPVConfigFileName: "osc=no\n", } for relativePath, content := range managedFiles { path := filepath.Join(configDir, relativePath) require.NoError(t, os.MkdirAll(filepath.Dir(path), constants.DirPermission)) require.NoError(t, os.WriteFile(path, []byte(content), constants.FilePermission)) } inst := NewInstaller(ReleaseInfo{}, configDir) cr, out := newBufferedRunner() require.NoError(t, InstallUISafely(inst, cr, configDir, constants.UITypeNone)) for relativePath := range managedFiles { if relativePath == constants.MPVConfigFileName { continue } assert.NoFileExists(t, filepath.Join(configDir, relativePath), relativePath) } conf, err := os.ReadFile(filepath.Join(configDir, constants.MPVConfigFileName)) require.NoError(t, err) assert.Contains(t, string(conf), "osc=yes") assert.Contains(t, drainOutput(out), "UI type set to none - using MPV's default interface") } func TestCreateFullBackupPrunesInsideLiteralBracketedProfile(t *testing.T) { isolateConfig(t) profile := filepath.Join(t.TempDir(), "[test]") require.NoError(t, os.Mkdir(profile, 0700)) for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"} { t.Setenv(key, profile) } t.Setenv("XDG_CONFIG_HOME", filepath.Join(profile, ".config")) _, err := config.Reload() require.NoError(t, err) configDir, err := GetMPVConfigDir() require.NoError(t, err) require.Contains(t, configDir, "[test]") require.NoError(t, os.MkdirAll(configDir, 0700)) live := filepath.Join(configDir, constants.MPVConfigFileName) require.NoError(t, os.WriteFile(live, []byte("private settings"), 0600)) backups, err := GetConfigBackupDir() require.NoError(t, err) require.NoError(t, os.Mkdir(backups, 0700)) oldest := "" for index := 0; index < maxConfigBackups; index++ { path := filepath.Join(backups, fmt.Sprintf("old-%02d%s", index, constants.BackupDirFilePrefix)) if index == 0 { oldest = path } require.NoError(t, os.WriteFile(path, []byte("old"), 0600)) timestamp := time.Unix(int64(index), 0) require.NoError(t, os.Chtimes(path, timestamp, timestamp)) } unrelated := filepath.Join(backups, "keep.txt") require.NoError(t, os.WriteFile(unrelated, []byte("keep"), 0600)) created, err := CreateFullBackup(OSFileSystem{}, live, nil) require.NoError(t, err) require.NoFileExists(t, oldest) assertTransactionFile(t, created, "private settings") assertTransactionFile(t, unrelated, "keep") entries, err := os.ReadDir(backups) require.NoError(t, err) require.Len(t, entries, maxConfigBackups+1) }