navidrome/plugins/host_kvstore_test.go
Deluan Quintão c9385fbb6b
test(plugins): build test plugins in Go instead of shelling out to make (#6060)
* test(plugins): build test plugins in Go instead of shelling out to make

The plugins suite built its .ndp test packages by running `make -C
plugins/testdata`, which needs make and zip on the PATH. That is the reason
the 26 WASM-dependent spec files are tagged //go:build !windows.

buildTestPlugins now does the same work in Go: the same mtime check make
performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip
for the package. TinyGo was already optional and unused in CI, so nothing is
lost there. The first plugin builds on its own so the shared wasip1 stdlib
and PDK objects land in the build cache before the rest fan out: on a cold
cache that is 2.2s against 3.4s for the sequential make and 7.3s for an
unrestrained fan-out.

Packaging moved into a writeNdp helper shared with createTestPackage, which
was already writing the same two-entry archive. Entries are written in a
fixed order, so the .ndp bytes are now reproducible; the loader hashes those
bytes, and `zip` also stored file mtimes, so the previous packages differed
on every rebuild.

The Makefile is unchanged and still works for building the plugins by hand.
Removing the !windows tags is a separate step, once CI is green here.

* test(plugins): run the WASM plugin specs on Windows

With the test plugins now built in Go, nothing in the suite needs a Unix
toolchain, so the //go:build !windows tags come off all 25 spec files. The
Windows CI job runs `go test ./...`, so it picks the suite up with no
workflow change.

plugins_suite_windows_test.go existed only to bootstrap the handful of specs
that compiled on Windows; plugins_suite_test.go now serves both.

* test(plugins): skip the planted-symlink spec where symlinks need privileges

os.Symlink needs an elevated token or Developer Mode on Windows, so the
unconditional Expect(...).To(Succeed()) would fail for contributors running
the suite on an ordinary Windows box. The elevated GitHub runner hides this.

The equivalent spec in sandbox_fs_internal_test.go already attempts the
symlink and skips on error; this does the same, keeping the pin live
everywhere it can run, including Windows CI.

* fix(ci): stop the Windows ndpgen test failing silently

The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows
will not exec a file without an executable extension, so the "supports
verbose mode" spec has been failing there. Nobody noticed because the
Test ndpgen step ran under pwsh, which carries on after a non-zero exit and
takes the step's status from the last command, so the job stayed green with
a FAIL line in its log.

Add the .exe suffix, and run the step under bash like the Linux job does, so
a failure in any of its three commands fails the job.
2026-08-31 21:42:10 -04:00

1038 lines
32 KiB
Go

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
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
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())
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")})
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()
svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")})
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())
})
})
})