navidrome/plugins/host_library_test.go
2025-12-31 17:06:33 -05:00

487 lines
15 KiB
Go

//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LibraryService", Ordered, func() {
var (
ctx context.Context
ds model.DataStore
service *libraryServiceImpl
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
ds = &tests.MockDataStore{}
})
Describe("GetLibrary", func() {
It("should return library metadata without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
lib := &model.Library{
ID: 1,
Name: "Test Library",
Path: "/music/test",
TotalSongs: 100,
TotalAlbums: 10,
TotalArtists: 5,
TotalSize: 1024000,
TotalDuration: 3600.5,
}
lib.LastScanAt = lib.LastScanAt.Add(0) // Ensure time is set
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{*lib})
result, err := service.GetLibrary(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(result.ID).To(Equal(int32(1)))
Expect(result.Name).To(Equal("Test Library"))
Expect(result.TotalSongs).To(Equal(int32(100)))
Expect(result.TotalAlbums).To(Equal(int32(10)))
Expect(result.TotalArtists).To(Equal(int32(5)))
Expect(result.TotalSize).To(Equal(int64(1024000)))
Expect(result.TotalDuration).To(Equal(3600.5))
Expect(result.Path).To(BeEmpty(), "Path should not be included without filesystem permission")
Expect(result.MountPoint).To(BeEmpty(), "MountPoint should not be included without filesystem permission")
})
It("should return library metadata with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
lib := &model.Library{
ID: 2,
Name: "FS Library",
Path: "/music/fs",
TotalSongs: 50,
TotalAlbums: 5,
TotalArtists: 3,
TotalSize: 512000,
TotalDuration: 1800.0,
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{*lib})
result, err := service.GetLibrary(ctx, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result.ID).To(Equal(int32(2)))
Expect(result.Name).To(Equal("FS Library"))
Expect(result.Path).To(Equal("/music/fs"), "Path should be included with filesystem permission")
Expect(result.MountPoint).To(Equal("/libraries/2"), "MountPoint should be included with filesystem permission")
})
It("should return error for non-existent library", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason}).(*libraryServiceImpl)
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{})
_, err := service.GetLibrary(ctx, 999)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library not found"))
})
})
Describe("GetAllLibraries", func() {
It("should return all libraries without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
results, err := service.GetAllLibraries(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
Expect(results[0].Name).To(Equal("Rock"))
Expect(results[0].Path).To(BeEmpty())
Expect(results[0].MountPoint).To(BeEmpty())
Expect(results[1].Name).To(Equal("Jazz"))
Expect(results[1].Path).To(BeEmpty())
Expect(results[1].MountPoint).To(BeEmpty())
})
It("should return all libraries with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
results, err := service.GetAllLibraries(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
Expect(results[0].Path).To(Equal("/music/rock"))
Expect(results[0].MountPoint).To(Equal("/libraries/1"))
Expect(results[1].Path).To(Equal("/music/jazz"))
Expect(results[1].MountPoint).To(Equal("/libraries/2"))
})
})
Describe("Plugin Integration", func() {
var (
manager *Manager
tmpDir string
)
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "library-test-*")
Expect(err).ToNot(HaveOccurred())
// Note: Since we don't have WASM test plugins yet, we can test
// the service registration and configuration without full plugin execution
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Create mock &tests.MockLibraryRepo{}
mockLibRepo := &tests.MockLibraryRepo{}
mockLibRepo.SetData(model.Libraries{
{ID: 1, Name: "Test", Path: "/tmp/test-music", TotalSongs: 10},
})
ds := &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedPlugin: tests.CreateMockPluginRepo(),
MockedLibrary: mockLibRepo,
}
manager = &Manager{
plugins: make(map[string]*plugin),
ds: ds,
}
DeferCleanup(func() {
if manager != nil {
_ = manager.Stop()
}
_ = os.RemoveAll(tmpDir)
})
})
It("should register library service in hostServices table", func() {
// Verify the library service is in the hostServices table
found := false
for _, entry := range hostServices {
if entry.name == "Library" {
found = true
break
}
}
Expect(found).To(BeTrue(), "Library service should be registered in hostServices")
})
It("should configure AllowedPaths when filesystem permission is granted", func() {
// This test verifies the AllowedPaths configuration logic
// We can't fully test without a real WASM plugin, but we can verify the setup
Expect(manager.ds).ToNot(BeNil())
ctx := context.Background()
libs, err := manager.ds.Library(adminContext(ctx)).GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(libs).To(HaveLen(1))
Expect(libs[0].Path).To(Equal("/tmp/test-music"))
// Verify mount point format
mountPoint := "/libraries/1"
Expect(mountPoint).To(MatchRegexp(`^/libraries/\d+$`))
})
})
})
var _ = Describe("LibraryService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
libraryDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "library-integration-test-*")
Expect(err).ToNot(HaveOccurred())
// Create a library directory with a test file
libraryDir = filepath.Join(tmpDir, "music-library")
err = os.MkdirAll(libraryDir, 0755)
Expect(err).ToNot(HaveOccurred())
// Create a test file in the library
testFile := filepath.Join(libraryDir, "test-track.txt")
err = os.WriteFile(testFile, []byte("test audio file content"), 0600)
Expect(err).ToNot(HaveOccurred())
// Copy the test-library plugin
srcPath := filepath.Join(testdataDir, "test-library"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-library"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore with pre-enabled plugin and library
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-library",
Path: destPath,
SHA256: hashHex,
Enabled: true,
}})
mockLibraryRepo := &tests.MockLibraryRepo{}
mockLibraryRepo.SetData(model.Libraries{
{
ID: 1,
Name: "Test Library",
Path: libraryDir,
TotalSongs: 100,
TotalAlbums: 10,
TotalArtists: 5,
TotalSize: 1024000,
TotalDuration: 3600.5,
},
{
ID: 2,
Name: "Jazz Collection",
Path: "/nonexistent/jazz",
TotalSongs: 50,
TotalAlbums: 5,
TotalArtists: 3,
TotalSize: 512000,
TotalDuration: 1800.0,
},
})
dataStore := &tests.MockDataStore{
MockedPlugin: mockPluginRepo,
MockedLibrary: mockLibraryRepo,
}
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Plugin Loading", func() {
It("should load plugin with library permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["test-library"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Library).ToNot(BeNil())
Expect(p.manifest.Permissions.Library.Filesystem).To(BeTrue())
})
})
Describe("Library Operations via Plugin", func() {
type testLibraryInput struct {
Operation string `json:"operation"`
LibraryID int32 `json:"library_id,omitempty"`
MountPoint string `json:"mount_point,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type library struct {
ID int32 `json:"id"`
Name string `json:"name"`
Path string `json:"path,omitempty"`
MountPoint string `json:"mountPoint,omitempty"`
LastScanAt int64 `json:"lastScanAt"`
TotalSongs int32 `json:"totalSongs"`
TotalAlbums int32 `json:"totalAlbums"`
TotalArtists int32 `json:"totalArtists"`
TotalSize int64 `json:"totalSize"`
TotalDuration float64 `json:"totalDuration"`
}
type testLibraryOutput struct {
Library *library `json:"library,omitempty"`
Libraries []library `json:"libraries,omitempty"`
FileContent string `json:"file_content,omitempty"`
DirEntries []string `json:"dir_entries,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestLibrary := func(ctx context.Context, input testLibraryInput) (*testLibraryOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-library"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_library", inputBytes)
if err != nil {
return nil, err
}
var output testLibraryOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
It("should get library by ID with metadata", func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{
Operation: "get_library",
LibraryID: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Library).ToNot(BeNil())
Expect(output.Library.ID).To(Equal(int32(1)))
Expect(output.Library.Name).To(Equal("Test Library"))
Expect(output.Library.TotalSongs).To(Equal(int32(100)))
Expect(output.Library.TotalAlbums).To(Equal(int32(10)))
Expect(output.Library.TotalArtists).To(Equal(int32(5)))
})
It("should include path and mount point with filesystem permission", func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{
Operation: "get_library",
LibraryID: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Library).ToNot(BeNil())
Expect(output.Library.Path).To(Equal(libraryDir))
Expect(output.Library.MountPoint).To(Equal("/libraries/1"))
})
It("should get all libraries", func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{
Operation: "get_all_libraries",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Libraries).To(HaveLen(2))
// First library
Expect(output.Libraries[0].ID).To(Equal(int32(1)))
Expect(output.Libraries[0].Name).To(Equal("Test Library"))
Expect(output.Libraries[0].MountPoint).To(Equal("/libraries/1"))
// Second library
Expect(output.Libraries[1].ID).To(Equal(int32(2)))
Expect(output.Libraries[1].Name).To(Equal("Jazz Collection"))
Expect(output.Libraries[1].MountPoint).To(Equal("/libraries/2"))
})
It("should return error for non-existent library", func() {
ctx := GinkgoT().Context()
_, err := callTestLibrary(ctx, testLibraryInput{
Operation: "get_library",
LibraryID: 999,
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library not found"))
})
// Note: This test is slightly flaky due to a potential race condition in wazero's
// WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts
// to automatically retry on failure.
It("should read file from mounted library directory", FlakeAttempts(3), func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{
Operation: "read_file",
MountPoint: "/libraries/1",
FilePath: "test-track.txt",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.FileContent).To(Equal("test audio file content"))
})
// Note: Uses FlakeAttempts for the same reason as the read_file test above
It("should list files in mounted library directory", FlakeAttempts(3), func() {
ctx := GinkgoT().Context()
output, err := callTestLibrary(ctx, testLibraryInput{
Operation: "list_dir",
MountPoint: "/libraries/1",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.DirEntries).To(ContainElement("test-track.txt"))
})
It("should fail to access unmapped library directory", func() {
ctx := GinkgoT().Context()
// Try to access a path outside the mapped libraries
_, err := callTestLibrary(ctx, testLibraryInput{
Operation: "list_dir",
MountPoint: "/etc",
})
Expect(err).To(HaveOccurred())
})
})
})