package plugins import ( "context" "encoding/json" "os" "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/plugins/schema" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) // Helper function to create test plugins with typed permissions func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPermissions) string { pluginDir := filepath.Join(tempDir, name) Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) // Use the generated PluginManifest type directly - it handles JSON marshaling automatically manifest := schema.PluginManifest{ Name: name, Author: "Test Author", Version: "1.0.0", Description: "Test plugin for permissions", Website: "https://test.navidrome.org/" + name, Capabilities: []schema.PluginManifestCapabilitiesElem{ schema.PluginManifestCapabilitiesElemMetadataAgent, }, Permissions: permissions, } // Marshal the typed manifest directly - gets all validation for free manifestData, err := json.Marshal(manifest) Expect(err).NotTo(HaveOccurred()) manifestPath := filepath.Join(pluginDir, "manifest.json") Expect(os.WriteFile(manifestPath, manifestData, 0600)).To(Succeed()) // Create fake WASM file (since plugin discovery checks for it) wasmPath := filepath.Join(pluginDir, "plugin.wasm") Expect(os.WriteFile(wasmPath, []byte("fake wasm content"), 0600)).To(Succeed()) return pluginDir } var _ = Describe("Plugin Permissions", func() { var ( mgr *Manager tempDir string ctx context.Context ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = context.Background() mgr = createManager(nil, nil) tempDir = GinkgoT().TempDir() }) Describe("Permission Enforcement in createRuntime", func() { It("should only load services specified in permissions", func() { // Test with limited permissions using typed structs permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, } runtimeFunc := mgr.createRuntime("test-plugin", permissions) // Create runtime to test service availability runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // The runtime was created successfully with the specified permissions Expect(runtime).NotTo(BeNil()) // Note: The actual verification of which specific host functions are available // would require introspecting the WASM runtime, which is complex. // The key test is that the runtime creation succeeds with valid permissions. }) It("should create runtime with empty permissions", func() { permissions := schema.PluginManifestPermissions{} runtimeFunc := mgr.createRuntime("empty-permissions-plugin", permissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // Should succeed but with no host services available Expect(runtime).NotTo(BeNil()) }) It("should handle all available permissions", func() { // Test with all possible permissions using typed structs permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, Scheduler: &schema.PluginManifestPermissionsScheduler{ Reason: "To schedule periodic tasks", }, Websocket: &schema.PluginManifestPermissionsWebsocket{ Reason: "To handle real-time communication", AllowedUrls: []string{"wss://api.example.com"}, AllowLocalNetwork: false, }, Cache: &schema.PluginManifestPermissionsCache{ Reason: "To cache data and reduce API calls", }, Artwork: &schema.PluginManifestPermissionsArtwork{ Reason: "To generate artwork URLs", }, } runtimeFunc := mgr.createRuntime("full-permissions-plugin", permissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) Expect(runtime).NotTo(BeNil()) }) }) Describe("Plugin Discovery with Permissions", func() { BeforeEach(func() { conf.Server.Plugins.Folder = tempDir }) It("should discover plugin with valid permissions manifest", func() { // Create plugin with http permission using typed structs permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch metadata from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, }, } createTestPlugin(tempDir, "valid-plugin", permissions) // Scan for plugins mgr.ScanPlugins() // Verify plugin was discovered (even without valid WASM) pluginNames := mgr.PluginNames("MetadataAgent") Expect(pluginNames).To(ContainElement("valid-plugin")) }) It("should discover plugin with no permissions", func() { // Create plugin with empty permissions using typed structs permissions := schema.PluginManifestPermissions{} createTestPlugin(tempDir, "no-perms-plugin", permissions) mgr.ScanPlugins() pluginNames := mgr.PluginNames("MetadataAgent") Expect(pluginNames).To(ContainElement("no-perms-plugin")) }) It("should discover plugin with multiple permissions", func() { // Create plugin with multiple permissions using typed structs permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch metadata from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read plugin configuration settings", }, Scheduler: &schema.PluginManifestPermissionsScheduler{ Reason: "To schedule periodic data updates", }, } createTestPlugin(tempDir, "multi-perms-plugin", permissions) mgr.ScanPlugins() pluginNames := mgr.PluginNames("MetadataAgent") Expect(pluginNames).To(ContainElement("multi-perms-plugin")) }) }) Describe("Existing Plugin Permissions", func() { BeforeEach(func() { // Use the testdata directory with updated plugins conf.Server.Plugins.Folder = testDataDir mgr.ScanPlugins() }) It("should discover fake_scrobbler with empty permissions", func() { scrobblerNames := mgr.PluginNames(CapabilityScrobbler) Expect(scrobblerNames).To(ContainElement("fake_scrobbler")) }) It("should discover multi_plugin with scheduler permissions", func() { agentNames := mgr.PluginNames(CapabilityMetadataAgent) Expect(agentNames).To(ContainElement("multi_plugin")) }) It("should discover all test plugins successfully", func() { // All test plugins should be discovered with their updated permissions testPlugins := []struct { name string capability string }{ {"fake_album_agent", CapabilityMetadataAgent}, {"fake_artist_agent", CapabilityMetadataAgent}, {"fake_scrobbler", CapabilityScrobbler}, {"multi_plugin", CapabilityMetadataAgent}, {"fake_init_service", CapabilityLifecycleManagement}, } for _, testPlugin := range testPlugins { pluginNames := mgr.PluginNames(testPlugin.capability) Expect(pluginNames).To(ContainElement(testPlugin.name), "Plugin %s should be discovered", testPlugin.name) } }) }) Describe("Permission Validation", func() { It("should enforce permissions are required in manifest", func() { // Create a manifest JSON string without the permissions field manifestContent := `{ "name": "test-plugin", "author": "Test Author", "version": "1.0.0", "description": "A test plugin", "website": "https://test.navidrome.org/test-plugin", "capabilities": ["MetadataAgent"] }` manifestPath := filepath.Join(tempDir, "manifest.json") err := os.WriteFile(manifestPath, []byte(manifestContent), 0600) Expect(err).NotTo(HaveOccurred()) _, err = LoadManifest(tempDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("field permissions in PluginManifest: required")) }) It("should allow unknown permission keys", func() { // Create manifest with both known and unknown permission types pluginDir := filepath.Join(tempDir, "unknown-perms") Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) manifestContent := `{ "name": "unknown-perms", "author": "Test Author", "version": "1.0.0", "description": "Manifest with unknown permissions", "website": "https://test.navidrome.org/unknown-perms", "capabilities": ["MetadataAgent"], "permissions": { "http": { "reason": "To fetch data from external APIs", "allowedUrls": { "*": ["*"] } }, "unknown": { "customField": "customValue" } } }` Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) // Test manifest loading directly - should succeed even with unknown permissions loadedManifest, err := LoadManifest(pluginDir) Expect(err).NotTo(HaveOccurred()) Expect(loadedManifest).NotTo(BeNil()) // With typed permissions, we check the specific fields Expect(loadedManifest.Permissions.Http).NotTo(BeNil()) Expect(loadedManifest.Permissions.Http.Reason).To(Equal("To fetch data from external APIs")) // The key point is that the manifest loads successfully despite unknown permissions // The actual handling of AdditionalProperties depends on the JSON schema implementation }) }) Describe("Runtime Pool with Permissions", func() { It("should create separate runtimes for different permission sets", func() { // Create two different permission sets using typed structs permissions1 := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, } permissions2 := schema.PluginManifestPermissions{ Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, } runtimeFunc1 := mgr.createRuntime("plugin1", permissions1) runtimeFunc2 := mgr.createRuntime("plugin2", permissions2) runtime1, err1 := runtimeFunc1(ctx) Expect(err1).NotTo(HaveOccurred()) defer runtime1.Close(ctx) runtime2, err2 := runtimeFunc2(ctx) Expect(err2).NotTo(HaveOccurred()) defer runtime2.Close(ctx) // Should be different runtime instances Expect(runtime1).NotTo(BeIdenticalTo(runtime2)) }) }) Describe("Permission System Integration", func() { It("should successfully validate manifests with permissions", func() { // Create a valid manifest with permissions pluginDir := filepath.Join(tempDir, "valid-manifest") Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) manifestContent := `{ "name": "valid-manifest", "author": "Test Author", "version": "1.0.0", "description": "Valid manifest with permissions", "website": "https://test.navidrome.org/valid-manifest", "capabilities": ["MetadataAgent"], "permissions": { "http": { "reason": "To fetch metadata from external APIs", "allowedUrls": { "*": ["*"] } }, "config": { "reason": "To read plugin configuration settings" } } }` Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) // Load the manifest - should succeed manifest, err := LoadManifest(pluginDir) Expect(err).NotTo(HaveOccurred()) Expect(manifest).NotTo(BeNil()) // With typed permissions, check the specific permission fields Expect(manifest.Permissions.Http).NotTo(BeNil()) Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata from external APIs")) Expect(manifest.Permissions.Config).NotTo(BeNil()) Expect(manifest.Permissions.Config.Reason).To(Equal("To read plugin configuration settings")) }) It("should track which services are requested per plugin", func() { // Test that different plugins can have different permission sets permissions1 := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, } permissions2 := schema.PluginManifestPermissions{ Scheduler: &schema.PluginManifestPermissionsScheduler{ Reason: "To schedule periodic tasks", }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration for scheduler", }, } permissions3 := schema.PluginManifestPermissions{} // Empty permissions createTestPlugin(tempDir, "plugin-with-http", permissions1) createTestPlugin(tempDir, "plugin-with-scheduler", permissions2) createTestPlugin(tempDir, "plugin-with-none", permissions3) conf.Server.Plugins.Folder = tempDir mgr.ScanPlugins() // All should be discovered pluginNames := mgr.PluginNames(CapabilityMetadataAgent) Expect(pluginNames).To(ContainElement("plugin-with-http")) Expect(pluginNames).To(ContainElement("plugin-with-scheduler")) Expect(pluginNames).To(ContainElement("plugin-with-none")) }) }) Describe("Runtime Service Access Control", func() { It("should successfully create runtime with permitted services", func() { // Create runtime with HTTP permission using typed struct permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, } runtimeFunc := mgr.createRuntime("http-only-plugin", permissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // Runtime should be created successfully - host functions are loaded during runtime creation Expect(runtime).NotTo(BeNil()) }) It("should successfully create runtime with multiple permitted services", func() { // Create runtime with multiple permissions using typed structs permissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, Scheduler: &schema.PluginManifestPermissionsScheduler{ Reason: "To schedule periodic tasks", }, } runtimeFunc := mgr.createRuntime("multi-service-plugin", permissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // Runtime should be created successfully Expect(runtime).NotTo(BeNil()) }) It("should create runtime with no services when no permissions granted", func() { // Create runtime with empty permissions using typed struct emptyPermissions := schema.PluginManifestPermissions{} runtimeFunc := mgr.createRuntime("no-service-plugin", emptyPermissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // Runtime should still be created, but with no host services Expect(runtime).NotTo(BeNil()) }) It("should demonstrate secure-by-default behavior", func() { // Test that default (empty permissions) provides no services defaultPermissions := schema.PluginManifestPermissions{} runtimeFunc := mgr.createRuntime("default-plugin", defaultPermissions) runtime, err := runtimeFunc(ctx) Expect(err).NotTo(HaveOccurred()) defer runtime.Close(ctx) // Runtime should be created but with no host services Expect(runtime).NotTo(BeNil()) }) It("should test permission enforcement by simulating unauthorized service access", func() { // This test demonstrates that plugins would fail at runtime when trying to call // host functions they don't have permission for, since those functions are simply // not loaded into the WASM runtime environment. // Create two different runtimes with different permissions using typed structs httpOnlyPermissions := schema.PluginManifestPermissions{ Http: &schema.PluginManifestPermissionsHttp{ Reason: "To fetch data from external APIs", AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, }, AllowLocalNetwork: false, }, } configOnlyPermissions := schema.PluginManifestPermissions{ Config: &schema.PluginManifestPermissionsConfig{ Reason: "To read configuration settings", }, } httpRuntime, err := mgr.createRuntime("http-only", httpOnlyPermissions)(ctx) Expect(err).NotTo(HaveOccurred()) defer httpRuntime.Close(ctx) configRuntime, err := mgr.createRuntime("config-only", configOnlyPermissions)(ctx) Expect(err).NotTo(HaveOccurred()) defer configRuntime.Close(ctx) // Both runtimes should be created successfully, but they will have different // sets of host functions available. A plugin trying to call unauthorized // functions would get "function not found" errors during instantiation or execution. Expect(httpRuntime).NotTo(BeNil()) Expect(configRuntime).NotTo(BeNil()) }) }) })