//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()) }) }) })