feat(plugins): add capability detection for plugins based on exported functions

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-21 21:29:45 -05:00
parent b3ec005fa2
commit 22561abadc
9 changed files with 427 additions and 365 deletions

View File

@ -85,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
PATH=$$PATH:./bin golangci-lint run --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code

View File

@ -44,34 +44,35 @@ Plugins must export an `nd_manifest` function that returns JSON:
"version": "1.0.0",
"description": "Plugin description",
"website": "https://example.com",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "Fetch metadata from external API",
"allowedUrls": {
"https://api.example.com/*": ["GET"]
}
"allowedHosts": ["api.example.com", "*.musicbrainz.org"]
}
}
}
```
**Note**: Capabilities are auto-detected based on which functions the plugin exports. You don't need to declare them in the manifest.
## Capabilities
Capabilities are automatically detected by examining which functions a plugin exports. There's no need to declare capabilities in the manifest.
### MetadataAgent
Provides artist and album metadata. Implement one or more of these functions:
Provides artist and album metadata. A plugin has this capability if it exports one or more of these functions:
| Function | Input | Output | Description |
|----------|-------|--------|-------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
| Function | Input | Output | Description |
|---------------------------|----------------------------|----------------------------------|----------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
## Developing Plugins
@ -88,19 +89,17 @@ import (
)
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
}
//go:wasmexport nd_manifest
func ndManifest() int32 {
manifest := Manifest{
Name: "My Plugin",
Author: "Me",
Version: "1.0.0",
Capabilities: []string{"MetadataAgent"},
Name: "My Plugin",
Author: "Me",
Version: "1.0.0",
}
out, _ := json.Marshal(manifest)
pdk.Output(out)
@ -143,7 +142,7 @@ tinygo build -o my-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go
### Using HTTP
Plugins can make HTTP requests using the Extism PDK. The host controls which URLs are allowed via the `permissions.http.allowedUrls` manifest field.
Plugins can make HTTP requests using the Extism PDK. The host controls which hosts are allowed via the `permissions.http.allowedHosts` manifest field.
```go
//go:wasmexport nd_get_artist_biography
@ -177,7 +176,7 @@ if !ok {
Plugins run in a secure WebAssembly sandbox with these restrictions:
1. **URL Allowlisting**: Only URLs listed in `permissions.http.allowedUrls` are accessible
1. **Host Allowlisting**: Only hosts listed in `permissions.http.allowedHosts` are accessible
2. **No File System Access**: Plugins cannot access the file system
3. **No Network Listeners**: Plugins cannot bind ports or create servers
4. **Config Isolation**: Plugins receive only their own config section

62
plugins/capabilities.go Normal file
View File

@ -0,0 +1,62 @@
package plugins
// Capability represents a plugin capability type.
// Capabilities are detected by checking which functions a plugin exports.
type Capability string
const (
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
// Detected when the plugin exports at least one of the metadata agent functions.
CapabilityMetadataAgent Capability = "MetadataAgent"
// Future capabilities:
// CapabilityScrobbler Capability = "Scrobbler"
)
// capabilityFunctions maps each capability to its required/optional functions.
// A plugin has a capability if it exports at least one of these functions.
var capabilityFunctions = map[Capability][]string{
CapabilityMetadataAgent: {
FuncGetArtistMBID,
FuncGetArtistURL,
FuncGetArtistBiography,
FuncGetSimilarArtists,
FuncGetArtistImages,
FuncGetArtistTopSongs,
FuncGetAlbumInfo,
FuncGetAlbumImages,
},
}
// functionExistsChecker is an interface for checking if a function exists in a plugin.
// This allows for testing without a real plugin instance.
type functionExistsChecker interface {
FunctionExists(name string) bool
}
// detectCapabilities detects which capabilities a plugin has by checking
// which functions it exports.
func detectCapabilities(plugin functionExistsChecker) []Capability {
var capabilities []Capability
for cap, functions := range capabilityFunctions {
for _, fn := range functions {
if plugin.FunctionExists(fn) {
capabilities = append(capabilities, cap)
break // Found at least one function, plugin has this capability
}
}
}
return capabilities
}
// hasCapability checks if the given capabilities slice contains a specific capability.
func hasCapability(capabilities []Capability, cap Capability) bool {
for _, c := range capabilities {
if c == cap {
return true
}
}
return false
}

View File

@ -0,0 +1,81 @@
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// mockFunctionChecker implements functionExistsChecker for testing
type mockFunctionChecker struct {
functions map[string]bool
}
func (m *mockFunctionChecker) FunctionExists(name string) bool {
return m.functions[name]
}
var _ = Describe("Capabilities", func() {
Describe("detectCapabilities", func() {
It("detects MetadataAgent capability when plugin exports artist biography function", func() {
checker := &mockFunctionChecker{
functions: map[string]bool{
FuncGetArtistBiography: true,
},
}
caps := detectCapabilities(checker)
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
})
It("detects MetadataAgent capability when plugin exports multiple functions", func() {
checker := &mockFunctionChecker{
functions: map[string]bool{
FuncGetArtistMBID: true,
FuncGetArtistURL: true,
FuncGetAlbumInfo: true,
FuncGetAlbumImages: true,
},
}
caps := detectCapabilities(checker)
Expect(caps).To(ContainElement(CapabilityMetadataAgent))
Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability
})
It("returns empty slice when no capability functions are exported", func() {
checker := &mockFunctionChecker{
functions: map[string]bool{
"some_other_function": true,
},
}
caps := detectCapabilities(checker)
Expect(caps).To(BeEmpty())
})
It("returns empty slice when plugin exports no functions", func() {
checker := &mockFunctionChecker{
functions: map[string]bool{},
}
caps := detectCapabilities(checker)
Expect(caps).To(BeEmpty())
})
})
Describe("hasCapability", func() {
It("returns true when capability exists", func() {
caps := []Capability{CapabilityMetadataAgent}
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue())
})
It("returns false when capability does not exist", func() {
caps := []Capability{}
Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse())
})
It("returns false when capabilities slice is nil", func() {
Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse())
})
})
})

View File

@ -2,6 +2,8 @@ package plugins
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@ -38,10 +40,11 @@ type Manager struct {
// pluginInstance represents a loaded plugin
type pluginInstance struct {
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
}
// GetManager returns a singleton instance of the plugin manager.
@ -121,6 +124,7 @@ func (m *Manager) Stop() error {
// PluginNames returns the names of all plugins that implement a particular capability.
// This is used by both agents and scrobbler systems to discover available plugins.
// Capabilities are auto-detected from the plugin's exported functions.
func (m *Manager) PluginNames(capability string) []string {
m.mu.RLock()
defer m.mu.RUnlock()
@ -128,7 +132,7 @@ func (m *Manager) PluginNames(capability string) []string {
var names []string
cap := Capability(capability)
for name, instance := range m.plugins {
if instance.manifest.HasCapability(cap) {
if hasCapability(instance.capabilities, cap) {
names = append(names, name)
}
}
@ -142,7 +146,7 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
instance, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !instance.manifest.HasCapability(CapabilityMetadataAgent) {
if !ok || !hasCapability(instance.capabilities, CapabilityMetadataAgent) {
return nil, false
}
@ -256,7 +260,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
RuntimeConfig: wazero.NewRuntimeConfig().WithCompilationCache(m.cache),
}
// Create temporary plugin to read manifest
// Create temporary plugin to read manifest and detect capabilities
tempPlugin, err := extism.NewPlugin(m.ctx, tempManifest, tempConfig, nil)
if err != nil {
return err
@ -272,15 +276,15 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
return err
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return err
}
if err := manifest.Validate(); err != nil {
return err
// Parse manifest (validation happens during unmarshal via generated code)
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return fmt.Errorf("invalid plugin manifest: %w", err)
}
// Detect capabilities based on exported functions
capabilities := detectCapabilities(tempPlugin)
// Now create the final compiled plugin with proper AllowedHosts
finalManifest := extism.Manifest{
Wasm: []extism.Wasm{
@ -306,10 +310,11 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
m.mu.Lock()
m.plugins[name] = &pluginInstance{
name: name,
path: wasmPath,
manifest: manifest,
compiled: compiled,
name: name,
path: wasmPath,
manifest: &manifest,
compiled: compiled,
capabilities: capabilities,
}
m.mu.Unlock()

View File

@ -1,182 +1,12 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
// Capability represents a plugin capability type
type Capability string
const (
CapabilityMetadataAgent Capability = "MetadataAgent"
// Future capabilities:
// CapabilityScrobbler Capability = "Scrobbler"
)
// Manifest represents the plugin manifest exported by the nd_manifest function.
// The manifest describes the plugin's metadata, capabilities, and permissions.
type Manifest struct {
Name string `json:"name"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Website string `json:"website,omitempty"`
Capabilities []Capability `json:"capabilities"`
Permissions Permissions `json:"permissions,omitempty"`
}
// Permissions defines the plugin's required permissions
type Permissions struct {
HTTP *HTTPPermission `json:"http,omitempty"`
Config *ConfigPermission `json:"config,omitempty"`
}
// HTTPPermission defines HTTP access permissions for a plugin
type HTTPPermission struct {
Reason string `json:"reason,omitempty"`
AllowedURLs map[string][]string `json:"allowedUrls,omitempty"`
}
// ConfigPermission defines config access permissions for a plugin
type ConfigPermission struct {
Reason string `json:"reason,omitempty"`
}
// Validate checks if the manifest is valid
func (m *Manifest) Validate() error {
if m.Name == "" {
return errors.New("plugin manifest: name is required")
}
if m.Author == "" {
return errors.New("plugin manifest: author is required")
}
if m.Version == "" {
return errors.New("plugin manifest: version is required")
}
if len(m.Capabilities) == 0 {
return errors.New("plugin manifest: at least one capability is required")
}
// Validate capabilities
for _, cap := range m.Capabilities {
if !isValidCapability(cap) {
return fmt.Errorf("plugin manifest: unknown capability %q", cap)
}
}
// Validate HTTP permissions if present
if m.Permissions.HTTP != nil {
if err := m.validateHTTPPermissions(); err != nil {
return err
}
}
return nil
}
// HasCapability checks if the plugin has a specific capability
func (m *Manifest) HasCapability(cap Capability) bool {
for _, c := range m.Capabilities {
if c == cap {
return true
}
}
return false
}
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest.json
// AllowedHosts returns a list of allowed hosts for HTTP requests.
// This extracts hostnames from the AllowedURLs patterns.
// Returns the hosts directly from the manifest's permissions.
func (m *Manifest) AllowedHosts() []string {
if m.Permissions.HTTP == nil || len(m.Permissions.HTTP.AllowedURLs) == 0 {
if m.Permissions == nil || m.Permissions.Http == nil {
return nil
}
hosts := make([]string, 0, len(m.Permissions.HTTP.AllowedURLs))
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
host := extractHost(urlPattern)
if host != "" {
hosts = append(hosts, host)
}
}
return hosts
}
// ParseManifest parses JSON data into a Manifest struct
func ParseManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("plugin manifest: invalid JSON: %w", err)
}
return &m, nil
}
// validateHTTPPermissions validates the HTTP permission configuration
func (m *Manifest) validateHTTPPermissions() error {
for urlPattern := range m.Permissions.HTTP.AllowedURLs {
if !isValidURLPattern(urlPattern) {
return fmt.Errorf("plugin manifest: invalid URL pattern %q", urlPattern)
}
}
return nil
}
// isValidCapability checks if a capability is known
func isValidCapability(cap Capability) bool {
switch cap {
case CapabilityMetadataAgent:
return true
default:
return false
}
}
// isValidURLPattern checks if a URL pattern is valid.
// Valid patterns are URLs that may contain wildcards (*).
// Examples:
// - https://api.example.com/*
// - https://*.example.com/api/*
// - https://example.com/v1/endpoint
func isValidURLPattern(pattern string) bool {
// Remove wildcards temporarily to validate the base URL
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
u, err := url.Parse(testURL)
if err != nil {
return false
}
// Must have a scheme (http or https)
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
// Must have a host
if u.Host == "" {
return false
}
return true
}
// extractHost extracts the hostname from a URL pattern.
// For patterns with wildcards in the host, it returns the pattern as-is for glob matching.
// Examples:
// - https://api.example.com/* -> api.example.com
// - https://*.example.com/api/* -> *.example.com
func extractHost(pattern string) string {
// Remove wildcards temporarily to parse the URL
testURL := strings.ReplaceAll(pattern, "*", "wildcard")
u, err := url.Parse(testURL)
if err != nil {
return ""
}
// Restore wildcards in the host
host := strings.ReplaceAll(u.Hostname(), "wildcard", "*")
return host
return m.Permissions.Http.AllowedHosts
}

82
plugins/manifest.json Normal file
View File

@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://navidrome.org/schemas/Manifest.json",
"title": "Manifest",
"description": "Plugin manifest for Navidrome plugins",
"type": "object",
"additionalProperties": false,
"required": ["name", "author", "version"],
"properties": {
"name": {
"type": "string",
"description": "The display name of the plugin",
"minLength": 1
},
"author": {
"type": "string",
"description": "The author of the plugin",
"minLength": 1
},
"version": {
"type": "string",
"description": "The version of the plugin (semver recommended)",
"minLength": 1
},
"description": {
"type": "string",
"description": "A brief description of what the plugin does"
},
"website": {
"type": "string",
"description": "URL to the plugin's website or repository",
"format": "uri"
},
"permissions": {
"$ref": "#/$defs/Permissions"
}
},
"$defs": {
"Permissions": {
"type": "object",
"description": "Permissions required by the plugin",
"additionalProperties": false,
"properties": {
"http": {
"$ref": "#/$defs/HTTPPermission"
},
"config": {
"$ref": "#/$defs/ConfigPermission"
}
}
},
"HTTPPermission": {
"type": "object",
"description": "HTTP access permissions for a plugin",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why HTTP access is needed"
},
"allowedHosts": {
"type": "array",
"description": "List of allowed host patterns for HTTP requests (e.g., 'api.example.com', '*.spotify.com')",
"items": {
"type": "string"
}
}
}
},
"ConfigPermission": {
"type": "object",
"description": "Configuration access permissions for a plugin",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why config access is needed"
}
}
}
}
}

85
plugins/manifest_gen.go Normal file
View File

@ -0,0 +1,85 @@
// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
package plugins
import "encoding/json"
import "fmt"
// Configuration access permissions for a plugin
type ConfigPermission struct {
// Explanation for why config access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// HTTP access permissions for a plugin
type HTTPPermission struct {
// List of allowed host patterns for HTTP requests (e.g., 'api.example.com',
// '*.spotify.com')
AllowedHosts []string `json:"allowedHosts,omitempty" yaml:"allowedHosts,omitempty" mapstructure:"allowedHosts,omitempty"`
// Explanation for why HTTP access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Plugin manifest for Navidrome plugins
type Manifest struct {
// The author of the plugin
Author string `json:"author" yaml:"author" mapstructure:"author"`
// A brief description of what the plugin does
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
// The display name of the plugin
Name string `json:"name" yaml:"name" mapstructure:"name"`
// Permissions corresponds to the JSON schema field "permissions".
Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty" mapstructure:"permissions,omitempty"`
// The version of the plugin (semver recommended)
Version string `json:"version" yaml:"version" mapstructure:"version"`
// URL to the plugin's website or repository
Website *string `json:"website,omitempty" yaml:"website,omitempty" mapstructure:"website,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *Manifest) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["author"]; raw != nil && !ok {
return fmt.Errorf("field author in Manifest: required")
}
if _, ok := raw["name"]; raw != nil && !ok {
return fmt.Errorf("field name in Manifest: required")
}
if _, ok := raw["version"]; raw != nil && !ok {
return fmt.Errorf("field version in Manifest: required")
}
type Plain Manifest
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if len(plain.Author) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "author", 1)
}
if len(plain.Name) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "name", 1)
}
if len(plain.Version) < 1 {
return fmt.Errorf("field %s length: must be >= %d", "version", 1)
}
*j = Manifest(plain)
return nil
}
// Permissions required by the plugin
type Permissions struct {
// Config corresponds to the JSON schema field "config".
Config *ConfigPermission `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
// Http corresponds to the JSON schema field "http".
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
}

View File

@ -1,12 +1,14 @@
package plugins
import (
"encoding/json"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manifest", func() {
Describe("ParseManifest", func() {
Describe("UnmarshalJSON", func() {
It("parses a valid manifest", func() {
data := []byte(`{
"name": "Test Plugin",
@ -14,217 +16,133 @@ var _ = Describe("Manifest", func() {
"version": "1.0.0",
"description": "A test plugin",
"website": "https://example.com",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "Fetch metadata",
"allowedUrls": {
"https://api.example.com/*": ["GET"]
}
"allowedHosts": ["api.example.com", "*.spotify.com"]
}
}
}`)
m, err := ParseManifest(data)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Test Plugin"))
Expect(m.Author).To(Equal("Test Author"))
Expect(m.Version).To(Equal("1.0.0"))
Expect(m.Description).To(Equal("A test plugin"))
Expect(m.Website).To(Equal("https://example.com"))
Expect(m.Capabilities).To(ContainElement(CapabilityMetadataAgent))
Expect(m.Permissions.HTTP).ToNot(BeNil())
Expect(m.Permissions.HTTP.Reason).To(Equal("Fetch metadata"))
Expect(m.Permissions.HTTP.AllowedURLs).To(HaveKey("https://api.example.com/*"))
Expect(*m.Description).To(Equal("A test plugin"))
Expect(*m.Website).To(Equal("https://example.com"))
Expect(m.Permissions.Http).ToNot(BeNil())
Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata"))
Expect(m.Permissions.Http.AllowedHosts).To(ContainElements("api.example.com", "*.spotify.com"))
})
It("parses a minimal manifest", func() {
data := []byte(`{
"name": "Minimal Plugin",
"author": "Author",
"version": "1.0.0"
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Minimal Plugin"))
Expect(m.Author).To(Equal("Author"))
Expect(m.Version).To(Equal("1.0.0"))
Expect(m.Description).To(BeNil())
Expect(m.Permissions).To(BeNil())
})
It("returns an error for invalid JSON", func() {
data := []byte(`{invalid json}`)
_, err := ParseManifest(data)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid JSON"))
})
})
Describe("Validate", func() {
It("returns an error when name is missing", func() {
m := &Manifest{
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
}
data := []byte(`{"author": "Test Author", "version": "1.0.0"}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("name is required"))
Expect(err.Error()).To(ContainSubstring("name"))
})
It("returns an error when author is missing", func() {
m := &Manifest{
Name: "Test Plugin",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
}
data := []byte(`{"name": "Test Plugin", "version": "1.0.0"}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("author is required"))
Expect(err.Error()).To(ContainSubstring("author"))
})
It("returns an error when version is missing", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Capabilities: []Capability{CapabilityMetadataAgent},
}
data := []byte(`{"name": "Test Plugin", "author": "Test Author"}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("version is required"))
Expect(err.Error()).To(ContainSubstring("version"))
})
It("returns an error when capabilities are missing", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
}
It("returns an error when name is empty", func() {
data := []byte(`{"name": "", "author": "Test Author", "version": "1.0.0"}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("at least one capability is required"))
Expect(err.Error()).To(ContainSubstring("name"))
})
It("returns an error for unknown capability", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{"UnknownCapability"},
}
It("returns an error when author is empty", func() {
data := []byte(`{"name": "Test Plugin", "author": "", "version": "1.0.0"}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unknown capability"))
Expect(err.Error()).To(ContainSubstring("author"))
})
It("returns an error for invalid URL pattern", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"not-a-valid-url": {"GET"},
},
},
},
}
It("returns an error when version is empty", func() {
data := []byte(`{"name": "Test Plugin", "author": "Test Author", "version": ""}`)
err := m.Validate()
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid URL pattern"))
})
It("validates a valid manifest", func() {
m := &Manifest{
Name: "Test Plugin",
Author: "Test Author",
Version: "1.0.0",
Capabilities: []Capability{CapabilityMetadataAgent},
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"https://api.example.com/*": {"GET"},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("HasCapability", func() {
It("returns true when capability exists", func() {
m := &Manifest{
Capabilities: []Capability{CapabilityMetadataAgent},
}
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeTrue())
})
It("returns false when capability does not exist", func() {
m := &Manifest{
Capabilities: []Capability{},
}
Expect(m.HasCapability(CapabilityMetadataAgent)).To(BeFalse())
Expect(err.Error()).To(ContainSubstring("version"))
})
})
Describe("AllowedHosts", func() {
It("returns nil when no HTTP permissions", func() {
It("returns nil when no permissions", func() {
m := &Manifest{}
Expect(m.AllowedHosts()).To(BeNil())
})
It("returns nil when no allowed URLs", func() {
It("returns nil when no HTTP permissions", func() {
m := &Manifest{
Permissions: Permissions{
HTTP: &HTTPPermission{},
},
Permissions: &Permissions{},
}
Expect(m.AllowedHosts()).To(BeNil())
})
It("extracts hosts from URL patterns", func() {
It("returns hosts from permissions", func() {
m := &Manifest{
Permissions: Permissions{
HTTP: &HTTPPermission{
AllowedURLs: map[string][]string{
"https://api.example.com/*": {"GET"},
"https://*.spotify.com/api/*": {"GET"},
},
Permissions: &Permissions{
Http: &HTTPPermission{
AllowedHosts: []string{"api.example.com", "*.spotify.com"},
},
},
}
hosts := m.AllowedHosts()
Expect(hosts).To(ContainElements("api.example.com", "*.spotify.com"))
Expect(hosts).To(Equal([]string{"api.example.com", "*.spotify.com"}))
})
})
Describe("isValidURLPattern", func() {
DescribeTable("validates URL patterns",
func(pattern string, expected bool) {
Expect(isValidURLPattern(pattern)).To(Equal(expected))
},
Entry("valid HTTPS URL", "https://api.example.com/path", true),
Entry("valid HTTP URL", "http://api.example.com/path", true),
Entry("URL with wildcard in path", "https://api.example.com/*", true),
Entry("URL with wildcard in host", "https://*.example.com/api/*", true),
Entry("missing scheme", "api.example.com/path", false),
Entry("invalid scheme", "ftp://api.example.com/path", false),
Entry("missing host", "https:///path", false),
)
})
Describe("extractHost", func() {
DescribeTable("extracts hosts from URL patterns",
func(pattern string, expected string) {
Expect(extractHost(pattern)).To(Equal(expected))
},
Entry("simple host", "https://api.example.com/path", "api.example.com"),
Entry("host with wildcard", "https://*.example.com/api/*", "*.example.com"),
Entry("host with port", "https://api.example.com:8080/path", "api.example.com"),
Entry("invalid URL", "not-a-url", ""),
)
})
})