quintodrome/plugins/host_kvstore_test.go
Deluan Quintão 8f0b4930ff
refactor(conf): replace eager dir creation with lazy Dir type (#5495)
* feat(conf): add Dir type with lazy directory creation

Introduces the Dir type that wraps a directory path string and defers
os.MkdirAll until the first call to Path() or MustPath(), using sync.Once
to ensure the creation happens exactly once. Implements fmt.Stringer,
encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration.
Includes Ginkgo/Gomega tests covering all methods and error paths.

* refactor(conf): replace eager dir creation with lazy Dir type

Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from
string to Dir. Remove all os.MkdirAll calls from Load() so directories
are created lazily on first Path()/MustPath() call. Artwork folder
creation was already handled at point-of-use in image_upload.go.

Add SnapshotConfig() to conf package for safe test config save/restore
that avoids copying sync.Once inside Dir fields. Fix copy-lock vet
warning in nativeapi/config.go by marshalling pointer instead of value.

* refactor(conf): migrate tests and db init to lazy Dir type

Update all test files to use conf.NewDir() for Dir field assignments.
Ensure DataFolder is created lazily when the database is first opened
in db.Db(). Remove eager directory creation from conf.Load() tests.

* fix(conf): address review findings for Dir type

- Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match
  original behavior). Add NewDirWithPerm for PluginsFolder (0700).
- Use Path() instead of MustPath() in db.Prune() to avoid logFatal
  from background cron job.
- Panic on marshal/unmarshal errors in SnapshotConfig (test helper).
- Clean up redundant String()/MustPath() calls in plugin manager.
- Remove dead code in dir_test.go.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(conf): add GoString to Dir for clean config dump output

Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path
string instead of internal struct fields (sync.Once, perm, err).
Also add TODO comment to configtest about removing the indirection.

* fix(dir): improve error logging in MustPath method

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(conf): address PR review feedback

- Ensure Plugins.Folder always uses 0700, even when user-configured
  (previously only the derived default got restrictive permissions).
- Create LogFile parent directory before opening, so LogFile paths
  inside a not-yet-created DataFolder work correctly.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-13 17:44:22 -03:00

1043 lines
32 KiB
Go

//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"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("KVStoreService", func() {
var tmpDir string
var service *kvstoreServiceImpl
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
var err error
tmpDir, err = os.MkdirTemp("", "kvstore-test-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create service with 1KB limit for testing
maxSize := "1KB"
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
if service != nil {
service.Close()
}
os.RemoveAll(tmpDir)
})
Describe("Basic Operations", func() {
It("sets and gets a value", func() {
err := service.Set(ctx, "key1", []byte("value1"))
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "key1")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("value1")))
})
It("returns not exists for missing key", func() {
value, exists, err := service.Get(ctx, "missing_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
It("overwrites existing key", func() {
err := service.Set(ctx, "key1", []byte("value1"))
Expect(err).ToNot(HaveOccurred())
err = service.Set(ctx, "key1", []byte("value2"))
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "key1")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("value2")))
})
It("handles binary data", func() {
binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}
err := service.Set(ctx, "binary", binaryData)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "binary")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal(binaryData))
})
})
Describe("Delete Operation", func() {
It("deletes a value", func() {
err := service.Set(ctx, "delete_me", []byte("value"))
Expect(err).ToNot(HaveOccurred())
err = service.Delete(ctx, "delete_me")
Expect(err).ToNot(HaveOccurred())
_, exists, err := service.Get(ctx, "delete_me")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("does not error when deleting non-existing key", func() {
err := service.Delete(ctx, "never_existed")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Has Operation", func() {
It("returns true for existing key", func() {
err := service.Set(ctx, "exists_key", []byte("value"))
Expect(err).ToNot(HaveOccurred())
exists, err := service.Has(ctx, "exists_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("List Operation", func() {
BeforeEach(func() {
Expect(service.Set(ctx, "user:1:name", []byte("Alice"))).To(Succeed())
Expect(service.Set(ctx, "user:1:email", []byte("alice@test.com"))).To(Succeed())
Expect(service.Set(ctx, "user:2:name", []byte("Bob"))).To(Succeed())
Expect(service.Set(ctx, "config:theme", []byte("dark"))).To(Succeed())
})
It("lists all keys with empty prefix", func() {
keys, err := service.List(ctx, "")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(4))
Expect(keys).To(ContainElements("config:theme", "user:1:email", "user:1:name", "user:2:name"))
})
It("lists keys matching prefix", func() {
keys, err := service.List(ctx, "user:1:")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(keys).To(ContainElements("user:1:name", "user:1:email"))
})
It("lists keys matching partial prefix", func() {
keys, err := service.List(ctx, "user:")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(3))
})
It("returns empty list for non-matching prefix", func() {
keys, err := service.List(ctx, "notfound:")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(BeEmpty())
})
It("handles special LIKE characters in prefix", func() {
// Add keys with special characters
Expect(service.Set(ctx, "test%key", []byte("value1"))).To(Succeed())
Expect(service.Set(ctx, "test_key", []byte("value2"))).To(Succeed())
Expect(service.Set(ctx, "testXkey", []byte("value3"))).To(Succeed())
// Search for "test%"
keys, err := service.List(ctx, "test%")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(1))
Expect(keys).To(ContainElement("test%key"))
})
})
Describe("Storage Usage", func() {
It("reports correct storage used", func() {
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(0)))
err = service.Set(ctx, "key1", []byte("12345"))
Expect(err).ToNot(HaveOccurred())
used, err = service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(5)))
err = service.Set(ctx, "key2", []byte("67890"))
Expect(err).ToNot(HaveOccurred())
used, err = service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(10)))
})
It("updates storage when value is overwritten", func() {
err := service.Set(ctx, "key1", []byte("12345"))
Expect(err).ToNot(HaveOccurred())
used, _ := service.GetStorageUsed(ctx)
Expect(used).To(Equal(int64(5)))
// Overwrite with smaller value
err = service.Set(ctx, "key1", []byte("ab"))
Expect(err).ToNot(HaveOccurred())
used, _ = service.GetStorageUsed(ctx)
Expect(used).To(Equal(int64(2)))
})
It("decreases storage when key is deleted", func() {
Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed())
Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed())
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(10)))
Expect(service.Delete(ctx, "key1")).To(Succeed())
used, err = service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(5)))
})
It("updates storage when value is overwritten with larger value", func() {
err := service.Set(ctx, "key1", []byte("ab"))
Expect(err).ToNot(HaveOccurred())
used, _ := service.GetStorageUsed(ctx)
Expect(used).To(Equal(int64(2)))
// Overwrite with larger value
err = service.Set(ctx, "key1", []byte("12345"))
Expect(err).ToNot(HaveOccurred())
used, _ = service.GetStorageUsed(ctx)
Expect(used).To(Equal(int64(5)))
})
It("restores correct size after service restart", func() {
// Add some data
Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed())
Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed())
used, _ := service.GetStorageUsed(ctx)
Expect(used).To(Equal(int64(10)))
// Close and reopen the service (simulating restart)
Expect(service.Close()).To(Succeed())
maxSize := "1KB"
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
// Size should be restored from database
used, err = service2.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(10)))
})
})
Describe("Size Limits", func() {
It("rejects value when storage limit would be exceeded", func() {
// Service has 1KB limit
bigValue := make([]byte, 2048)
err := service.Set(ctx, "big", bigValue)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("storage limit exceeded"))
})
It("allows updating existing key even if total would exceed limit", func() {
// Fill up most of the storage
almostFull := make([]byte, 900)
err := service.Set(ctx, "big", almostFull)
Expect(err).ToNot(HaveOccurred())
// Overwrite with same size should work
err = service.Set(ctx, "big", almostFull)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Key Validation", func() {
It("rejects empty key", func() {
err := service.Set(ctx, "", []byte("value"))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("key cannot be empty"))
})
It("rejects key exceeding max length", func() {
longKey := strings.Repeat("a", 300)
err := service.Set(ctx, longKey, []byte("value"))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("key exceeds maximum length"))
})
})
Describe("Plugin Isolation", func() {
It("isolates data between plugins", func() {
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
// Set same key in both plugins
err = service.Set(ctx, "shared", []byte("value1"))
Expect(err).ToNot(HaveOccurred())
err = service2.Set(ctx, "shared", []byte("value2"))
Expect(err).ToNot(HaveOccurred())
// Each plugin should get their own value
val1, _, _ := service.Get(ctx, "shared")
Expect(val1).To(Equal([]byte("value1")))
val2, _, _ := service2.Get(ctx, "shared")
Expect(val2).To(Equal([]byte("value2")))
})
It("creates separate database files per plugin", func() {
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
Expect(err).ToNot(HaveOccurred())
defer service2.Close()
// Check that separate directories exist
_, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "kvstore.db"))
Expect(err).ToNot(HaveOccurred())
_, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "kvstore.db"))
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Close", func() {
It("closes database connection", func() {
err := service.Close()
Expect(err).ToNot(HaveOccurred())
// After close, operations should fail
_, _, err = service.Get(ctx, "any")
Expect(err).To(HaveOccurred())
})
})
Describe("TTL Expiration", func() {
It("Get returns not-exists for expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_key', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "expired_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
Expect(value).To(BeNil())
})
It("Has returns false for expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_has', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
exists, err := service.Has(ctx, "expired_has")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("List excludes expired keys", func() {
Expect(service.Set(ctx, "live:1", []byte("alive"))).To(Succeed())
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('live:expired', 'dead', 4, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
keys, err := service.List(ctx, "live:")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(1))
Expect(keys).To(ContainElement("live:1"))
})
It("Get returns value for non-expired keys with TTL", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('future_key', 'still alive', 11, datetime('now', '+3600 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "future_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("still alive")))
})
It("Set clears expires_at from a key previously set with TTL", func() {
// Insert a key with a TTL that has already expired
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('ttl_then_set', 'temp', 4, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Overwrite with Set (no TTL) — should become permanent
err = service.Set(ctx, "ttl_then_set", []byte("permanent"))
Expect(err).ToNot(HaveOccurred())
// Should exist because Set cleared expires_at
value, exists, err := service.Get(ctx, "ttl_then_set")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("permanent")))
// Verify expires_at is actually NULL
var expiresAt *string
Expect(service.db.QueryRow(`SELECT expires_at FROM kvstore WHERE key = 'ttl_then_set'`).Scan(&expiresAt)).To(Succeed())
Expect(expiresAt).To(BeNil())
})
It("expired keys are not counted in storage used", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_key', '12345', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Expired keys should not be counted
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(0)))
})
It("cleanup removes expired rows from disk", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('cleanup_me', '12345', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Row exists in DB but is logically expired
var count int
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
Expect(count).To(Equal(1))
service.cleanupExpired(ctx)
// Row should be physically deleted
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
Expect(count).To(Equal(0))
})
})
Describe("Close", func() {
It("does not race with cleanupLoop goroutine", func() {
// Create a service with a dedicated context so we can verify
// that Close() properly waits for the cleanup goroutine.
closeCtx, closeCancel := context.WithCancel(ctx)
defer closeCancel()
maxSize := "1KB"
svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize})
Expect(err).ToNot(HaveOccurred())
// Insert an expired key so cleanup has work to do
_, err = svc.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('cleanup_race', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Close should not panic or produce "database is closed" errors.
// Before the fix, the cleanup goroutine could race with db.Close().
err = svc.Close()
Expect(err).ToNot(HaveOccurred())
// Verify the database is actually closed (further queries should fail)
_, err = svc.db.Exec(`SELECT 1`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("database is closed"))
})
})
Describe("SetWithTTL", func() {
It("stores value that is retrievable before expiry", func() {
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "ttl_key")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("ttl_value")))
})
It("value is not retrievable after expiry", func() {
// Insert a key with an already-expired TTL
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('short_ttl', 'gone_soon', 9, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
_, exists, err := service.Get(ctx, "short_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("rejects ttlSeconds <= 0", func() {
err := service.SetWithTTL(ctx, "bad_ttl", []byte("value"), 0)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
err = service.SetWithTTL(ctx, "bad_ttl", []byte("value"), -5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
})
It("validates key same as Set", func() {
err := service.SetWithTTL(ctx, "", []byte("value"), 60)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("key cannot be empty"))
})
It("enforces size limits same as Set", func() {
bigValue := make([]byte, 2048)
err := service.SetWithTTL(ctx, "big_ttl", bigValue, 60)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("storage limit exceeded"))
})
It("overwrites existing key and updates TTL", func() {
// Insert a key with an already-expired TTL
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('overwrite_ttl', 'first', 5, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
// Overwrite with a long TTL — should be retrievable
err = service.SetWithTTL(ctx, "overwrite_ttl", []byte("second"), 3600)
Expect(err).ToNot(HaveOccurred())
value, exists, err := service.Get(ctx, "overwrite_ttl")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
Expect(value).To(Equal([]byte("second")))
})
It("tracks storage correctly", func() {
err := service.SetWithTTL(ctx, "sized_ttl", []byte("12345"), 3600)
Expect(err).ToNot(HaveOccurred())
used, err := service.GetStorageUsed(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(used).To(Equal(int64(5)))
})
})
Describe("DeleteByPrefix", func() {
BeforeEach(func() {
Expect(service.Set(ctx, "cache:user:1", []byte("Alice"))).To(Succeed())
Expect(service.Set(ctx, "cache:user:2", []byte("Bob"))).To(Succeed())
Expect(service.Set(ctx, "cache:item:1", []byte("Widget"))).To(Succeed())
Expect(service.Set(ctx, "data:important", []byte("keep"))).To(Succeed())
})
It("deletes all keys with the given prefix", func() {
deleted, err := service.DeleteByPrefix(ctx, "cache:user:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(2)))
keys, err := service.List(ctx, "")
Expect(err).ToNot(HaveOccurred())
Expect(keys).To(HaveLen(2))
Expect(keys).To(ContainElements("cache:item:1", "data:important"))
})
It("rejects empty prefix", func() {
_, err := service.DeleteByPrefix(ctx, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("prefix cannot be empty"))
})
It("returns 0 when no keys match", func() {
deleted, err := service.DeleteByPrefix(ctx, "nonexistent:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(0)))
})
It("updates storage size correctly", func() {
usedBefore, _ := service.GetStorageUsed(ctx)
Expect(usedBefore).To(BeNumerically(">", 0))
_, err := service.DeleteByPrefix(ctx, "cache:")
Expect(err).ToNot(HaveOccurred())
usedAfter, _ := service.GetStorageUsed(ctx)
Expect(usedAfter).To(Equal(int64(4)))
})
It("handles special LIKE characters in prefix", func() {
Expect(service.Set(ctx, "test%special", []byte("v1"))).To(Succeed())
Expect(service.Set(ctx, "test_special", []byte("v2"))).To(Succeed())
Expect(service.Set(ctx, "testXspecial", []byte("v3"))).To(Succeed())
deleted, err := service.DeleteByPrefix(ctx, "test%")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(1)))
exists, _ := service.Has(ctx, "test_special")
Expect(exists).To(BeTrue())
exists, _ = service.Has(ctx, "testXspecial")
Expect(exists).To(BeTrue())
})
It("also deletes expired keys matching prefix", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('cache:expired', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
deleted, err := service.DeleteByPrefix(ctx, "cache:")
Expect(err).ToNot(HaveOccurred())
Expect(deleted).To(Equal(int64(4)))
})
})
Describe("GetMany", func() {
BeforeEach(func() {
Expect(service.Set(ctx, "key1", []byte("value1"))).To(Succeed())
Expect(service.Set(ctx, "key2", []byte("value2"))).To(Succeed())
Expect(service.Set(ctx, "key3", []byte("value3"))).To(Succeed())
})
It("retrieves multiple values at once", func() {
values, err := service.GetMany(ctx, []string{"key1", "key2", "key3"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(3))
Expect(values["key1"]).To(Equal([]byte("value1")))
Expect(values["key2"]).To(Equal([]byte("value2")))
Expect(values["key3"]).To(Equal([]byte("value3")))
})
It("omits missing keys from result", func() {
values, err := service.GetMany(ctx, []string{"key1", "missing", "key3"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(2))
Expect(values["key1"]).To(Equal([]byte("value1")))
Expect(values["key3"]).To(Equal([]byte("value3")))
_, hasMissing := values["missing"]
Expect(hasMissing).To(BeFalse())
})
It("returns empty map for empty keys slice", func() {
values, err := service.GetMany(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("returns empty map for nil keys slice", func() {
values, err := service.GetMany(ctx, nil)
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
It("excludes expired keys", func() {
_, err := service.db.Exec(`
INSERT INTO kvstore (key, value, size, expires_at)
VALUES ('expired_many', 'old', 3, datetime('now', '-1 seconds'))
`)
Expect(err).ToNot(HaveOccurred())
values, err := service.GetMany(ctx, []string{"key1", "expired_many"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(HaveLen(1))
Expect(values["key1"]).To(Equal([]byte("value1")))
})
It("handles all keys missing", func() {
values, err := service.GetMany(ctx, []string{"nope1", "nope2"})
Expect(err).ToNot(HaveOccurred())
Expect(values).To(BeEmpty())
})
})
})
var _ = Describe("KVStoreService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "kvstore-integration-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-kvstore plugin
srcPath := filepath.Join(testdataDir, "test-kvstore"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-kvstore"+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
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Setup mock DataStore with pre-enabled plugin
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-kvstore",
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 kvstore permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["test-kvstore"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Kvstore).ToNot(BeNil())
Expect(*p.manifest.Permissions.Kvstore.MaxSize).To(Equal("10KB"))
})
})
Describe("KVStore Operations via Plugin", func() {
type testKVStoreInput struct {
Operation string `json:"operation"`
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
Prefix string `json:"prefix,omitempty"`
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
Keys []string `json:"keys,omitempty"`
}
type testKVStoreOutput struct {
Value []byte `json:"value,omitempty"`
Values map[string][]byte `json:"values,omitempty"`
Exists bool `json:"exists,omitempty"`
Keys []string `json:"keys,omitempty"`
StorageUsed int64 `json:"storage_used,omitempty"`
DeletedCount int64 `json:"deleted_count,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-kvstore"]
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_kvstore", inputBytes)
if err != nil {
return nil, err
}
var output testKVStoreOutput
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 value", func() {
ctx := GinkgoT().Context()
// Set value
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: "test_key",
Value: []byte("hello kvstore"),
})
Expect(err).ToNot(HaveOccurred())
// Get value
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "test_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.Value).To(Equal([]byte("hello kvstore")))
})
It("should check key existence with has", func() {
ctx := GinkgoT().Context()
// Check existing key
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "test_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Check non-existing key
output, err = callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "non_existing",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
It("should delete value", func() {
ctx := GinkgoT().Context()
// Set another key
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: "to_delete",
Value: []byte("delete me"),
})
Expect(err).ToNot(HaveOccurred())
// Delete it
_, err = callTestKVStore(ctx, testKVStoreInput{
Operation: "delete",
Key: "to_delete",
})
Expect(err).ToNot(HaveOccurred())
// Verify it's gone
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "to_delete",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
})
It("should list keys with prefix", func() {
ctx := GinkgoT().Context()
// Set some keys
for _, key := range []string{"prefix:1", "prefix:2", "other:1"} {
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: key,
Value: []byte("value"),
})
Expect(err).ToNot(HaveOccurred())
}
// List with prefix
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "list",
Prefix: "prefix:",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Keys).To(HaveLen(2))
Expect(output.Keys).To(ContainElements("prefix:1", "prefix:2"))
})
It("should report storage used", func() {
ctx := GinkgoT().Context()
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get_storage_used",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.StorageUsed).To(BeNumerically(">", 0))
})
It("should enforce size limits", func() {
ctx := GinkgoT().Context()
// Plugin has 10KB limit, try to exceed it
bigValue := make([]byte, 15*1024)
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: "too_big",
Value: bigValue,
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("storage limit exceeded"))
})
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 value
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: "binary_test",
Value: binaryData,
})
Expect(err).ToNot(HaveOccurred())
// Get binary value and verify exact match
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "binary_test",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.Value).To(Equal(binaryData))
})
It("should set value with TTL and expire it", func() {
ctx := GinkgoT().Context()
// Set value with 1 second TTL
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set_with_ttl",
Key: "ttl_key",
Value: []byte("temporary"),
TTLSeconds: 1,
})
Expect(err).ToNot(HaveOccurred())
// Immediately should exist
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "ttl_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.Value).To(Equal([]byte("temporary")))
// Poll until the key expires (1s TTL)
Eventually(func(g Gomega) {
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get",
Key: "ttl_key",
})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(output.Exists).To(BeFalse())
}).WithTimeout(3 * time.Second).WithPolling(200 * time.Millisecond).Should(Succeed())
})
It("should delete keys by prefix", func() {
ctx := GinkgoT().Context()
// Set multiple keys with shared prefix
for _, key := range []string{"del_prefix:a", "del_prefix:b", "keep:c"} {
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: key,
Value: []byte("value"),
})
Expect(err).ToNot(HaveOccurred())
}
// Delete by prefix
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "delete_by_prefix",
Prefix: "del_prefix:",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.DeletedCount).To(Equal(int64(2)))
// Verify remaining key
getOutput, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "keep:c",
})
Expect(err).ToNot(HaveOccurred())
Expect(getOutput.Exists).To(BeTrue())
// Verify deleted keys are gone
getOutput, err = callTestKVStore(ctx, testKVStoreInput{
Operation: "has",
Key: "del_prefix:a",
})
Expect(err).ToNot(HaveOccurred())
Expect(getOutput.Exists).To(BeFalse())
})
It("should get many values at once", func() {
ctx := GinkgoT().Context()
// Set multiple keys
for _, kv := range []struct{ k, v string }{
{"many:1", "val1"},
{"many:2", "val2"},
{"many:3", "val3"},
} {
_, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "set",
Key: kv.k,
Value: []byte(kv.v),
})
Expect(err).ToNot(HaveOccurred())
}
// Get many, including a missing key
output, err := callTestKVStore(ctx, testKVStoreInput{
Operation: "get_many",
Keys: []string{"many:1", "many:3", "many:missing"},
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Values).To(HaveLen(2))
Expect(output.Values["many:1"]).To(Equal([]byte("val1")))
Expect(output.Values["many:3"]).To(Equal([]byte("val3")))
_, hasMissing := output.Values["many:missing"]
Expect(hasMissing).To(BeFalse())
})
})
Describe("Database Isolation", func() {
It("should create separate database file for plugin", func() {
dbPath := filepath.Join(tmpDir, "plugins", "test-kvstore", "kvstore.db")
_, err := os.Stat(dbPath)
Expect(err).ToNot(HaveOccurred())
})
})
})