Files
navidrome/plugins/host_cache_test.go

603 lines
16 KiB
Go

//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"time"
"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("CacheService", func() {
var service *cacheServiceImpl
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
service = newCacheService("test_plugin")
})
AfterEach(func() {
if service != nil {
service.Close()
}
})
Describe("getTTL", func() {
It("returns default TTL when seconds is 0", func() {
ttl := service.getTTL(0)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns default TTL when seconds is negative", func() {
ttl := service.getTTL(-10)
Expect(ttl).To(Equal(defaultCacheTTL))
})
It("returns correct duration when seconds is positive", func() {
ttl := service.getTTL(60)
Expect(ttl).To(Equal(time.Minute))
})
})
Describe("Plugin Isolation", func() {
It("isolates keys between plugins", func() {
service1 := newCacheService("plugin1")
defer service1.Close()
service2 := newCacheService("plugin2")
defer service2.Close()
// Both plugins set same key
err := service1.SetString(ctx, "shared", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = service2.SetString(ctx, "shared", "value2", 0)
Expect(err).ToNot(HaveOccurred())
// Each plugin should get their own value
val1, exists1, err := service1.GetString(ctx, "shared")
Expect(err).ToNot(HaveOccurred())
Expect(exists1).To(BeTrue())
Expect(val1).To(Equal("value1"))
val2, exists2, err := service2.GetString(ctx, "shared")
Expect(err).ToNot(HaveOccurred())
Expect(exists2).To(BeTrue())
Expect(val2).To(Equal("value2"))
})
})
Describe("String Operations", func() {
It("sets and gets a string value", func() {
err := service.SetString(ctx, "string_key", "test_value", 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetString(ctx, "string_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal("test_value"))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetString(ctx, "missing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(""))
})
})
Describe("Integer Operations", func() {
It("sets and gets an integer value", func() {
err := service.SetInt(ctx, "int_key", 42, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetInt(ctx, "int_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(int64(42)))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetInt(ctx, "missing_int_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(int64(0)))
})
})
Describe("Float Operations", func() {
It("sets and gets a float value", func() {
err := service.SetFloat(ctx, "float_key", 3.14, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetFloat(ctx, "float_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(3.14))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetFloat(ctx, "missing_float_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(float64(0)))
})
})
Describe("Bytes Operations", func() {
It("sets and gets a bytes value", func() {
byteData := []byte("hello world")
err := service.SetBytes(ctx, "bytes_key", byteData, 300)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetBytes(ctx, "bytes_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(byteData))
})
It("returns not exists for missing key", func() {
value, exists, err := service.GetBytes(ctx, "missing_bytes_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
})
Describe("Type mismatch handling", func() {
It("returns not exists when type doesn't match the getter", func() {
// Set string
err := service.SetString(ctx, "mixed_key", "string value", 0)
Expect(err).ToNot(HaveOccurred())
// Try to get as int
value, exists, err := service.GetInt(ctx, "mixed_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(int64(0)))
})
It("returns not exists when getting string as float", func() {
err := service.SetString(ctx, "str_as_float", "not a float", 0)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetFloat(ctx, "str_as_float")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(Equal(float64(0)))
})
It("returns not exists when getting int as bytes", func() {
err := service.SetInt(ctx, "int_as_bytes", 123, 0)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.GetBytes(ctx, "int_as_bytes")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
})
Describe("Has Operation", func() {
It("returns true for existing key", func() {
err := service.SetString(ctx, "existing_key", "exists", 0)
Expect(err).ToNot(HaveOccurred())
exists, err := service.Has(ctx, "existing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
It("returns false for non-existing key", func() {
exists, err := service.Has(ctx, "non_existing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
})
Describe("Remove Operation", func() {
It("removes a value from the cache", func() {
// Set a value
err := service.SetString(ctx, "remove_key", "to be removed", 0)
Expect(err).ToNot(HaveOccurred())
// Verify it exists
exists, err := service.Has(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
// Remove it
err = service.Remove(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
// Verify it's gone
exists, err = service.Has(ctx, "remove_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("does not error when removing non-existing key", func() {
err := service.Remove(ctx, "never_existed")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("TTL Behavior", func() {
It("uses default TTL when 0 is provided", func() {
err := service.SetString(ctx, "default_ttl", "value", 0)
Expect(err).ToNot(HaveOccurred())
// Value should exist immediately
exists, err := service.Has(ctx, "default_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
It("uses custom TTL when provided", func() {
err := service.SetString(ctx, "custom_ttl", "value", 300)
Expect(err).ToNot(HaveOccurred())
// Value should exist immediately
exists, err := service.Has(ctx, "custom_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
})
Describe("Close", func() {
It("removes all cache entries for the plugin", func() {
// Use a dedicated service for this test
closeService := newCacheService("close_test_plugin")
// Set multiple values
err := closeService.SetString(ctx, "key1", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = closeService.SetInt(ctx, "key2", 42, 0)
Expect(err).ToNot(HaveOccurred())
err = closeService.SetFloat(ctx, "key3", 3.14, 0)
Expect(err).ToNot(HaveOccurred())
// Verify they exist
exists, _ := closeService.Has(ctx, "key1")
Expect(exists).To(BeTrue())
exists, _ = closeService.Has(ctx, "key2")
Expect(exists).To(BeTrue())
exists, _ = closeService.Has(ctx, "key3")
Expect(exists).To(BeTrue())
// Close the service
err = closeService.Close()
Expect(err).ToNot(HaveOccurred())
// All entries should be gone
exists, _ = closeService.Has(ctx, "key1")
Expect(exists).To(BeFalse())
exists, _ = closeService.Has(ctx, "key2")
Expect(exists).To(BeFalse())
exists, _ = closeService.Has(ctx, "key3")
Expect(exists).To(BeFalse())
})
It("does not affect other plugins' cache entries", func() {
// Create two services for different plugins
service1 := newCacheService("plugin_close_test1")
service2 := newCacheService("plugin_close_test2")
defer service2.Close()
// Set values for both plugins
err := service1.SetString(ctx, "key", "value1", 0)
Expect(err).ToNot(HaveOccurred())
err = service2.SetString(ctx, "key", "value2", 0)
Expect(err).ToNot(HaveOccurred())
// Close only service1
err = service1.Close()
Expect(err).ToNot(HaveOccurred())
// service1's key should be gone
exists, _ := service1.Has(ctx, "key")
Expect(exists).To(BeFalse())
// service2's key should still exist
exists, _ = service2.Has(ctx, "key")
Expect(exists).To(BeTrue())
})
})
})
var _ = Describe("CacheService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "cache-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-cache-plugin
srcPath := filepath.Join(testdataDir, "test-cache-plugin"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-cache-plugin"+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
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-cache-plugin",
Path: destPath,
SHA256: hashHex,
Enabled: true,
}})
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// 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 cache permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["test-cache-plugin"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Cache).ToNot(BeNil())
})
})
Describe("Cache Operations via Plugin", func() {
type testCacheInput struct {
Operation string `json:"operation"`
Key string `json:"key"`
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
FloatVal float64 `json:"float_val,omitempty"`
BytesVal []byte `json:"bytes_val,omitempty"`
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
}
type testCacheOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
FloatVal float64 `json:"float_val,omitempty"`
BytesVal []byte `json:"bytes_val,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestCache := func(ctx context.Context, input testCacheInput) (*testCacheOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-cache-plugin"]
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_cache", inputBytes)
if err != nil {
return nil, err
}
var output testCacheOutput
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 set and get string value", func() {
ctx := GinkgoT().Context()
// Set string
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "test_string",
StringVal: "hello world",
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get string
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_string",
Key: "test_string",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.StringVal).To(Equal("hello world"))
})
It("should set and get integer value", func() {
ctx := GinkgoT().Context()
// Set int
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_int",
Key: "test_int",
IntVal: 42,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get int
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_int",
Key: "test_int",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.IntVal).To(Equal(int64(42)))
})
It("should set and get float value", func() {
ctx := GinkgoT().Context()
// Set float
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_float",
Key: "test_float",
FloatVal: 3.14159,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get float
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_float",
Key: "test_float",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.FloatVal).To(Equal(3.14159))
})
It("should set and get bytes value", func() {
ctx := GinkgoT().Context()
testBytes := []byte{0x01, 0x02, 0x03, 0x04}
// Set bytes
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_bytes",
Key: "test_bytes",
BytesVal: testBytes,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get bytes
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_bytes",
Key: "test_bytes",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.BytesVal).To(Equal(testBytes))
})
It("should handle binary data with null bytes through WASM", func() {
ctx := GinkgoT().Context()
// Binary data with null bytes, high bytes, and other edge cases
binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x80, 0x7F}
// Set binary bytes
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_bytes",
Key: "binary_test",
BytesVal: binaryData,
TTLSeconds: 300,
})
Expect(err).ToNot(HaveOccurred())
// Get binary bytes and verify exact match
output, err := callTestCache(ctx, testCacheInput{
Operation: "get_bytes",
Key: "binary_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.BytesVal).To(Equal(binaryData))
})
It("should check if key exists", func() {
ctx := GinkgoT().Context()
// Set a value
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "exists_test",
StringVal: "value",
})
Expect(err).ToNot(HaveOccurred())
// Check has
output, err := callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "exists_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Check non-existent
output, err = callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "nonexistent",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
It("should remove a key", func() {
ctx := GinkgoT().Context()
// Set a value
_, err := callTestCache(ctx, testCacheInput{
Operation: "set_string",
Key: "remove_test",
StringVal: "value",
})
Expect(err).ToNot(HaveOccurred())
// Remove it
_, err = callTestCache(ctx, testCacheInput{
Operation: "remove",
Key: "remove_test",
})
Expect(err).ToNot(HaveOccurred())
// Verify it's gone
output, err := callTestCache(ctx, testCacheInput{
Operation: "has",
Key: "remove_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
})
})