414 lines
12 KiB
Go
414 lines
12 KiB
Go
package library
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/torrentclaw/unarr/internal/agent"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isWithinScanPaths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIsWithinScanPaths(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
scanPaths []string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "file inside scan path",
|
|
path: "/media/movies/Inception.mkv",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "file in subdirectory of scan path",
|
|
path: "/media/movies/2024/Inception/Inception.mkv",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "file at scan path root itself",
|
|
path: "/media/movies",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: false, // rel == "."
|
|
},
|
|
{
|
|
name: "file outside all scan paths",
|
|
path: "/tmp/evil.mkv",
|
|
scanPaths: []string{"/media/movies", "/media/shows"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "dotdot traversal attempt",
|
|
path: "/media/movies/../../../etc/passwd",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "multiple scan paths file in second",
|
|
path: "/media/shows/Breaking.Bad.S01E01.mkv",
|
|
scanPaths: []string{"/media/movies", "/media/shows"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "empty scan paths",
|
|
path: "/media/movies/file.mkv",
|
|
scanPaths: []string{},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isWithinScanPaths(tt.path, tt.scanPaths)
|
|
if got != tt.want {
|
|
t.Errorf("isWithinScanPaths(%q, %v) = %v, want %v", tt.path, tt.scanPaths, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// dirEligibleForPrune
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDirEligibleForPrune(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dir string
|
|
scanPaths []string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "scan root itself is NOT eligible",
|
|
dir: "/media/movies",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "subdirectory IS eligible",
|
|
dir: "/media/movies/2024",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "parent of scan path is NOT eligible",
|
|
dir: "/media",
|
|
scanPaths: []string{"/media/movies"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "trailing slash normalization — root not eligible",
|
|
dir: "/media/movies",
|
|
scanPaths: []string{"/media/movies/"},
|
|
want: false, // filepath.Clean removes trailing slash
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := dirEligibleForPrune(tt.dir, tt.scanPaths)
|
|
if got != tt.want {
|
|
t.Errorf("dirEligibleForPrune(%q, %v) = %v, want %v", tt.dir, tt.scanPaths, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deleteOne
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDeleteOne(t *testing.T) {
|
|
t.Run("delete existing file inside scan path", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
file := filepath.Join(root, "movie.mkv")
|
|
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := deleteOne(file, []string{root}); err != nil {
|
|
t.Fatalf("deleteOne returned error: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
t.Error("file should have been deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("reject relative path", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
err := deleteOne("relative/path.mkv", []string{root})
|
|
if err == nil {
|
|
t.Fatal("expected error for relative path")
|
|
}
|
|
if got := err.Error(); got != `path is not absolute: "relative/path.mkv"` {
|
|
t.Errorf("unexpected error message: %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("reject path outside scan paths", func(t *testing.T) {
|
|
scanRoot := t.TempDir()
|
|
outsideDir := t.TempDir()
|
|
file := filepath.Join(outsideDir, "secret.txt")
|
|
if err := os.WriteFile(file, []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := deleteOne(file, []string{scanRoot})
|
|
if err == nil {
|
|
t.Fatal("expected error for path outside scan paths")
|
|
}
|
|
|
|
// File must NOT have been deleted.
|
|
if _, statErr := os.Stat(file); statErr != nil {
|
|
t.Error("file outside scan path should NOT have been deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("file already deleted is idempotent", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Reference a file that does not exist.
|
|
file := filepath.Join(root, "gone.mkv")
|
|
|
|
if err := deleteOne(file, []string{root}); err != nil {
|
|
t.Fatalf("expected idempotent success, got error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("symlink pointing outside scan path is rejected", func(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require elevated privileges on Windows")
|
|
}
|
|
|
|
scanRoot := t.TempDir()
|
|
outsideDir := t.TempDir()
|
|
outsideFile := filepath.Join(outsideDir, "real.mkv")
|
|
if err := os.WriteFile(outsideFile, []byte("real"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
link := filepath.Join(scanRoot, "link.mkv")
|
|
if err := os.Symlink(outsideFile, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := deleteOne(link, []string{scanRoot})
|
|
if err == nil {
|
|
t.Fatal("expected error: symlink target is outside scan paths")
|
|
}
|
|
|
|
// The real file must NOT have been deleted.
|
|
if _, statErr := os.Stat(outsideFile); statErr != nil {
|
|
t.Error("symlink target outside scan path should NOT have been deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("symlink pointing inside scan path is allowed", func(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require elevated privileges on Windows")
|
|
}
|
|
|
|
scanRoot := t.TempDir()
|
|
subdir := filepath.Join(scanRoot, "sub")
|
|
if err := os.Mkdir(subdir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
realFile := filepath.Join(subdir, "real.mkv")
|
|
if err := os.WriteFile(realFile, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
link := filepath.Join(scanRoot, "link.mkv")
|
|
if err := os.Symlink(realFile, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := deleteOne(link, []string{scanRoot}); err != nil {
|
|
t.Fatalf("deleteOne returned error: %v", err)
|
|
}
|
|
|
|
// The real file should have been deleted (os.Remove on resolved path).
|
|
if _, statErr := os.Stat(realFile); !os.IsNotExist(statErr) {
|
|
t.Error("resolved target inside scan path should have been deleted")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pruneEmptyDirs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestPruneEmptyDirs(t *testing.T) {
|
|
t.Run("empty parent dir is removed", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "show")
|
|
if err := os.Mkdir(sub, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pruneEmptyDirs(sub, []string{root})
|
|
|
|
if _, err := os.Stat(sub); !os.IsNotExist(err) {
|
|
t.Error("empty subdirectory should have been removed")
|
|
}
|
|
// Scan root must still exist.
|
|
if _, err := os.Stat(root); err != nil {
|
|
t.Error("scan path root should NOT have been removed")
|
|
}
|
|
})
|
|
|
|
t.Run("non-empty parent dir is NOT removed", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
sub := filepath.Join(root, "show")
|
|
if err := os.Mkdir(sub, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Put a file inside so it's not empty.
|
|
if err := os.WriteFile(filepath.Join(sub, "keep.txt"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pruneEmptyDirs(sub, []string{root})
|
|
|
|
if _, err := os.Stat(sub); err != nil {
|
|
t.Error("non-empty directory should NOT have been removed")
|
|
}
|
|
})
|
|
|
|
t.Run("stops at scan path root", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Create an empty dir that IS the scan root.
|
|
// pruneEmptyDirs should refuse to remove it.
|
|
pruneEmptyDirs(root, []string{root})
|
|
|
|
if _, err := os.Stat(root); err != nil {
|
|
t.Error("scan path root should never be removed")
|
|
}
|
|
})
|
|
|
|
t.Run("multi-level cleanup", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
deep := filepath.Join(root, "a", "b", "c")
|
|
if err := os.MkdirAll(deep, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pruneEmptyDirs(deep, []string{root})
|
|
|
|
// All three levels (a, a/b, a/b/c) should be removed.
|
|
for _, dir := range []string{
|
|
filepath.Join(root, "a", "b", "c"),
|
|
filepath.Join(root, "a", "b"),
|
|
filepath.Join(root, "a"),
|
|
} {
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Errorf("directory should have been removed: %s", dir)
|
|
}
|
|
}
|
|
|
|
// Scan root must still exist.
|
|
if _, err := os.Stat(root); err != nil {
|
|
t.Error("scan path root should NOT have been removed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeleteFiles (integration)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDeleteFiles(t *testing.T) {
|
|
t.Run("multiple items some valid some invalid", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
outsideDir := t.TempDir()
|
|
goodFile := filepath.Join(root, "good.mkv")
|
|
if err := os.WriteFile(goodFile, []byte("ok"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outsideFile := filepath.Join(outsideDir, "outside.mkv")
|
|
if err := os.WriteFile(outsideFile, []byte("nope"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
items := []agent.LibraryDeleteRequest{
|
|
{ItemID: 1, FilePath: goodFile}, // valid → deleted
|
|
{ItemID: 2, FilePath: "relative/bad.mkv"}, // relative → rejected
|
|
{ItemID: 3, FilePath: outsideFile}, // outside scan paths → rejected
|
|
{ItemID: 4, FilePath: filepath.Join(root, "gone.mkv")}, // not-exist → idempotent success
|
|
}
|
|
|
|
confirmed := DeleteFiles(items, []string{root})
|
|
|
|
// Items 1 and 4 should succeed. Item 2 (relative) and 3 (outside) should fail.
|
|
want := map[int]bool{1: true, 4: true}
|
|
got := make(map[int]bool, len(confirmed))
|
|
for _, id := range confirmed {
|
|
got[id] = true
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("confirmed = %v, want IDs %v", confirmed, want)
|
|
}
|
|
for id := range want {
|
|
if !got[id] {
|
|
t.Errorf("expected item %d to be confirmed", id)
|
|
}
|
|
}
|
|
|
|
// outsideFile must NOT have been deleted.
|
|
if _, err := os.Stat(outsideFile); err != nil {
|
|
t.Error("file outside scan paths should NOT have been deleted")
|
|
}
|
|
|
|
// good.mkv should be deleted.
|
|
if _, err := os.Stat(goodFile); !os.IsNotExist(err) {
|
|
t.Error("good.mkv should have been deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("empty scan paths returns nil", func(t *testing.T) {
|
|
items := []agent.LibraryDeleteRequest{
|
|
{ItemID: 1, FilePath: "/some/file.mkv"},
|
|
}
|
|
confirmed := DeleteFiles(items, []string{})
|
|
if confirmed != nil {
|
|
t.Errorf("expected nil, got %v", confirmed)
|
|
}
|
|
})
|
|
|
|
t.Run("all relative scan paths returns nil", func(t *testing.T) {
|
|
items := []agent.LibraryDeleteRequest{
|
|
{ItemID: 1, FilePath: "/some/file.mkv"},
|
|
}
|
|
confirmed := DeleteFiles(items, []string{"relative/path", "another/relative"})
|
|
if confirmed != nil {
|
|
t.Errorf("expected nil, got %v", confirmed)
|
|
}
|
|
})
|
|
|
|
t.Run("mixed absolute and relative scan paths uses only absolute", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
file := filepath.Join(root, "movie.mkv")
|
|
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
items := []agent.LibraryDeleteRequest{
|
|
{ItemID: 10, FilePath: file},
|
|
}
|
|
confirmed := DeleteFiles(items, []string{"relative/bad", root})
|
|
|
|
if len(confirmed) != 1 || confirmed[0] != 10 {
|
|
t.Errorf("confirmed = %v, want [10]", confirmed)
|
|
}
|
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
t.Error("file should have been deleted via the absolute scan path")
|
|
}
|
|
})
|
|
}
|