🏡 index : quine-global/quintodrome.git

//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 = conf.NewDir(tmpDir)
		conf.Server.Plugins.AutoReload = false

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