package fileutil import ( "encoding/json" "fmt" "os" "path/filepath" "runtime" "testing" ) func TestMain(m *testing.M) { tmpRoot, err := os.MkdirTemp("", "fileutil-test-*") if err != nil { panic(err) } if err := os.Setenv("TMPDIR", tmpRoot); err != nil { panic(err) } code := m.Run() _ = os.RemoveAll(tmpRoot) os.Exit(code) } func mustMarshal(t *testing.T, v any) []byte { t.Helper() data, err := json.MarshalIndent(v, "", " ") if err != nil { t.Fatal(err) } return data } func isolatedTempDir(t *testing.T) string { t.Helper() return t.TempDir() } func TestWriteWithBackup(t *testing.T) { tmpDir := isolatedTempDir(t) t.Run("creates file", func(t *testing.T) { path := filepath.Join(tmpDir, "new.json") data := mustMarshal(t, map[string]string{"key": "value"}) if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var result map[string]string if err := json.Unmarshal(content, &result); err != nil { t.Fatal(err) } if result["key"] != "value" { t.Errorf("expected value, got %s", result["key"]) } }) t.Run("creates backup in the temp backup directory", func(t *testing.T) { path := filepath.Join(tmpDir, "backup.json") os.WriteFile(path, []byte(`{"original": true}`), 0o644) data := mustMarshal(t, map[string]bool{"updated": true}) if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } entries, err := os.ReadDir(BackupDir()) if err != nil { t.Fatal("backup directory not created") } var foundBackup bool for _, entry := range entries { if filepath.Ext(entry.Name()) != ".json" { name := entry.Name() if len(name) > len("backup.json.") && name[:len("backup.json.")] == "backup.json." { backupPath := filepath.Join(BackupDir(), name) backup, err := os.ReadFile(backupPath) if err == nil { var backupData map[string]bool json.Unmarshal(backup, &backupData) if backupData["original"] { foundBackup = true os.Remove(backupPath) break } } } } } if !foundBackup { t.Error("backup file not created in backup directory") } current, _ := os.ReadFile(path) var currentData map[string]bool json.Unmarshal(current, ¤tData) if !currentData["updated"] { t.Error("file doesn't contain updated data") } }) t.Run("no backup for new file", func(t *testing.T) { path := filepath.Join(tmpDir, "nobak.json") data := mustMarshal(t, map[string]string{"new": "file"}) if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } entries, _ := os.ReadDir(BackupDir()) for _, entry := range entries { if len(entry.Name()) > len("nobak.json.") && entry.Name()[:len("nobak.json.")] == "nobak.json." { t.Error("backup should not exist for new file") } } }) t.Run("no backup when content unchanged", func(t *testing.T) { path := filepath.Join(tmpDir, "unchanged.json") data := mustMarshal(t, map[string]string{"key": "value"}) if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } entries1, _ := os.ReadDir(BackupDir()) countBefore := 0 for _, e := range entries1 { if len(e.Name()) > len("unchanged.json.") && e.Name()[:len("unchanged.json.")] == "unchanged.json." { countBefore++ } } if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } entries2, _ := os.ReadDir(BackupDir()) countAfter := 0 for _, e := range entries2 { if len(e.Name()) > len("unchanged.json.") && e.Name()[:len("unchanged.json.")] == "unchanged.json." { countAfter++ } } if countAfter != countBefore { t.Errorf("backup was created when content unchanged (before=%d, after=%d)", countBefore, countAfter) } }) t.Run("backup filename contains unix timestamp", func(t *testing.T) { path := filepath.Join(tmpDir, "timestamped.json") os.WriteFile(path, []byte(`{"v": 1}`), 0o644) data := mustMarshal(t, map[string]int{"v": 2}) if err := WriteWithBackup(path, data); err != nil { t.Fatal(err) } entries, _ := os.ReadDir(BackupDir()) var found bool for _, entry := range entries { name := entry.Name() if len(name) > len("timestamped.json.") && name[:len("timestamped.json.")] == "timestamped.json." { timestamp := name[len("timestamped.json."):] for _, c := range timestamp { if c < '0' || c > '9' { t.Errorf("backup filename timestamp contains non-numeric character: %s", name) } } found = true os.Remove(filepath.Join(BackupDir(), name)) break } } if !found { t.Error("backup file with timestamp not found") } }) } // Edge case tests for files.go // TestWriteWithBackup_FailsIfBackupFails documents critical behavior: if backup fails, we must not proceed. // User could lose their config with no way to recover. func TestWriteWithBackup_FailsIfBackupFails(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission tests unreliable on Windows") } tmpDir := isolatedTempDir(t) path := filepath.Join(tmpDir, "config.json") // Create original file originalContent := []byte(`{"original": true}`) os.WriteFile(path, originalContent, 0o644) // Make backup directory read-only to force backup failure backupDir := BackupDir() os.MkdirAll(backupDir, 0o755) os.Chmod(backupDir, 0o444) // Read-only defer os.Chmod(backupDir, 0o755) newContent := []byte(`{"updated": true}`) err := WriteWithBackup(path, newContent) // Should fail because backup couldn't be created if err == nil { t.Error("expected error when backup fails, got nil") } // Original file should be preserved current, _ := os.ReadFile(path) if string(current) != string(originalContent) { t.Errorf("original file was modified despite backup failure: got %s", string(current)) } } // TestWriteWithBackup_PermissionDenied verifies clear error when target file has wrong permissions. // Common issue when config owned by root or wrong perms. func TestWriteWithBackup_PermissionDenied(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission tests unreliable on Windows") } tmpDir := isolatedTempDir(t) // Create a read-only directory readOnlyDir := filepath.Join(tmpDir, "readonly") os.MkdirAll(readOnlyDir, 0o755) os.Chmod(readOnlyDir, 0o444) defer os.Chmod(readOnlyDir, 0o755) path := filepath.Join(readOnlyDir, "config.json") err := WriteWithBackup(path, []byte(`{"test": true}`)) if err == nil { t.Error("expected permission error, got nil") } } // TestWriteWithBackup_DirectoryDoesNotExist verifies behavior when target directory doesn't exist. // writeWithBackup doesn't create directories - caller is responsible. func TestWriteWithBackup_DirectoryDoesNotExist(t *testing.T) { tmpDir := isolatedTempDir(t) path := filepath.Join(tmpDir, "nonexistent", "subdir", "config.json") err := WriteWithBackup(path, []byte(`{"test": true}`)) // Should fail because directory doesn't exist if err == nil { t.Error("expected error for nonexistent directory, got nil") } } // TestWriteWithBackup_SymlinkTarget documents behavior when target is a symlink. // Documents what happens if user symlinks their config file. func TestWriteWithBackup_SymlinkTarget(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink tests may require admin on Windows") } tmpDir := isolatedTempDir(t) realFile := filepath.Join(tmpDir, "real.json") symlink := filepath.Join(tmpDir, "link.json") // Create real file and symlink os.WriteFile(realFile, []byte(`{"v": 1}`), 0o644) os.Symlink(realFile, symlink) // Write through symlink err := WriteWithBackup(symlink, []byte(`{"v": 2}`)) if err != nil { t.Fatalf("writeWithBackup through symlink failed: %v", err) } // The real file should be updated (symlink followed for temp file creation) content, _ := os.ReadFile(symlink) if string(content) != `{"v": 2}` { t.Errorf("symlink target not updated correctly: got %s", string(content)) } } // TestBackupToTmp_SpecialCharsInFilename verifies backup works with special characters. // User may have config files with unusual names. func TestBackupToTmp_SpecialCharsInFilename(t *testing.T) { tmpDir := isolatedTempDir(t) // File with spaces and special chars path := filepath.Join(tmpDir, "my config (backup).json") os.WriteFile(path, []byte(`{"test": true}`), 0o644) backupPath, err := backupToTmp(path) if err != nil { t.Fatalf("backupToTmp with special chars failed: %v", err) } // Verify backup exists and has correct content content, err := os.ReadFile(backupPath) if err != nil { t.Fatalf("could not read backup: %v", err) } if string(content) != `{"test": true}` { t.Errorf("backup content mismatch: got %s", string(content)) } os.Remove(backupPath) } // TestCopyFile_PreservesPermissions verifies that copyFile preserves file permissions. func TestCopyFile_PreservesPermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission preservation tests unreliable on Windows") } tmpDir := isolatedTempDir(t) src := filepath.Join(tmpDir, "src.json") dst := filepath.Join(tmpDir, "dst.json") // Create source with specific permissions os.WriteFile(src, []byte(`{"test": true}`), 0o600) err := copyFile(src, dst) if err != nil { t.Fatalf("copyFile failed: %v", err) } srcInfo, _ := os.Stat(src) dstInfo, _ := os.Stat(dst) if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { t.Errorf("permissions not preserved: src=%v, dst=%v", srcInfo.Mode().Perm(), dstInfo.Mode().Perm()) } } // TestCopyFile_SourceNotFound verifies clear error when source doesn't exist. func TestCopyFile_SourceNotFound(t *testing.T) { tmpDir := isolatedTempDir(t) src := filepath.Join(tmpDir, "nonexistent.json") dst := filepath.Join(tmpDir, "dst.json") err := copyFile(src, dst) if err == nil { t.Error("expected error for nonexistent source, got nil") } } // TestWriteWithBackup_TargetIsDirectory verifies error when path points to a directory. func TestWriteWithBackup_TargetIsDirectory(t *testing.T) { tmpDir := isolatedTempDir(t) dirPath := filepath.Join(tmpDir, "actualdir") os.MkdirAll(dirPath, 0o755) err := WriteWithBackup(dirPath, []byte(`{"test": true}`)) if err == nil { t.Error("expected error when target is a directory, got nil") } } // TestWriteWithBackup_EmptyData verifies writing zero bytes works correctly. func TestWriteWithBackup_EmptyData(t *testing.T) { tmpDir := isolatedTempDir(t) path := filepath.Join(tmpDir, "empty.json") err := WriteWithBackup(path, []byte{}) if err != nil { t.Fatalf("writeWithBackup with empty data failed: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("could not read file: %v", err) } if len(content) != 0 { t.Errorf("expected empty file, got %d bytes", len(content)) } } // TestWriteWithBackup_FileUnreadableButDirWritable verifies behavior when existing file // cannot be read (for backup comparison) but directory is writable. func TestWriteWithBackup_FileUnreadableButDirWritable(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission tests unreliable on Windows") } tmpDir := isolatedTempDir(t) path := filepath.Join(tmpDir, "unreadable.json") // Create file and make it unreadable os.WriteFile(path, []byte(`{"original": true}`), 0o644) os.Chmod(path, 0o000) defer os.Chmod(path, 0o644) // Should fail because we can't read the file to compare/backup err := WriteWithBackup(path, []byte(`{"updated": true}`)) if err == nil { t.Error("expected error when file is unreadable, got nil") } } // TestWriteWithBackup_RapidSuccessiveWrites verifies backup works with multiple writes // within the same second (timestamp collision scenario). func TestWriteWithBackup_RapidSuccessiveWrites(t *testing.T) { tmpDir := isolatedTempDir(t) path := filepath.Join(tmpDir, "rapid.json") // Create initial file os.WriteFile(path, []byte(`{"v": 0}`), 0o644) // Rapid successive writes for i := 1; i <= 3; i++ { data := []byte(fmt.Sprintf(`{"v": %d}`, i)) if err := WriteWithBackup(path, data); err != nil { t.Fatalf("write %d failed: %v", i, err) } } // Verify final content content, _ := os.ReadFile(path) if string(content) != `{"v": 3}` { t.Errorf("expected final content {\"v\": 3}, got %s", string(content)) } // Verify at least one backup exists entries, _ := os.ReadDir(BackupDir()) var backupCount int for _, e := range entries { if len(e.Name()) > len("rapid.json.") && e.Name()[:len("rapid.json.")] == "rapid.json." { backupCount++ } } if backupCount == 0 { t.Error("expected at least one backup file from rapid writes") } } // TestWriteWithBackup_BackupDirIsFile verifies error when backup directory path is a file. func TestWriteWithBackup_BackupDirIsFile(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test modifies system temp directory") } tmpDir := isolatedTempDir(t) // Create a file at the backup directory path backupPath := BackupDir() // Clean up any existing directory first os.RemoveAll(backupPath) // Create a file instead of directory os.WriteFile(backupPath, []byte("not a directory"), 0o644) defer func() { os.Remove(backupPath) os.MkdirAll(backupPath, 0o755) }() path := filepath.Join(tmpDir, "test.json") os.WriteFile(path, []byte(`{"original": true}`), 0o644) err := WriteWithBackup(path, []byte(`{"updated": true}`)) if err == nil { t.Error("expected error when backup dir is a file, got nil") } } // TestWriteWithBackup_NoOrphanTempFiles verifies temp files are cleaned up on failure. func TestWriteWithBackup_NoOrphanTempFiles(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("permission tests unreliable on Windows") } tmpDir := isolatedTempDir(t) // Count existing temp files countTempFiles := func() int { entries, _ := os.ReadDir(tmpDir) count := 0 for _, e := range entries { if len(e.Name()) > 4 && e.Name()[:4] == ".tmp" { count++ } } return count } before := countTempFiles() // Create a file, then make directory read-only to cause rename failure path := filepath.Join(tmpDir, "orphan.json") os.WriteFile(path, []byte(`{"v": 1}`), 0o644) // Make a subdirectory and try to write there after making parent read-only subDir := filepath.Join(tmpDir, "subdir") os.MkdirAll(subDir, 0o755) subPath := filepath.Join(subDir, "config.json") os.WriteFile(subPath, []byte(`{"v": 1}`), 0o644) // Make subdir read-only after creating temp file would succeed but rename would fail // This is tricky to test - the temp file is created in the same dir, so if we can't // rename, we also couldn't create. Let's just verify normal failure cleanup works. // Force a failure by making the target a directory badPath := filepath.Join(tmpDir, "isdir") os.MkdirAll(badPath, 0o755) _ = WriteWithBackup(badPath, []byte(`{"test": true}`)) after := countTempFiles() if after > before { t.Errorf("orphan temp files left behind: before=%d, after=%d", before, after) } }