feat: add plugin UI host (sidebar, view container, settings panel) + storage API
- internal/core/storage/api.go — plugin namespace JSON storage (settings/data/cache) - internal/core/storage/api_test.go — 8 tests (write/read, path traversal, atomic) - internal/api/app.go — Wails bindings for storage (Read/WritePluginSettings, Read/WritePluginDataJSON) - main.go — initialize storage service, pass to NewApp - Sidebar.svelte — plugin sidebar items from contributions (filtered by ui.register) - ViewContainer.svelte — plugin view host with degraded status - PluginCard.svelte — Settings button + permission warnings - PluginManager.svelte — settings panel modal with test form - App.svelte — integrated sidebar + view container layout
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ type App struct {
|
||||
eventBus *events.Bus
|
||||
plugins []plugin.Plugin
|
||||
vault *vault.Vault
|
||||
storage *storage.Storage
|
||||
}
|
||||
|
||||
// NewApp creates a new App instance.
|
||||
@@ -34,6 +36,7 @@ func NewApp(
|
||||
bus *events.Bus,
|
||||
plugins []plugin.Plugin,
|
||||
vaultService *vault.Vault,
|
||||
storageService *storage.Storage,
|
||||
) *App {
|
||||
return &App{
|
||||
capRegistry: capReg,
|
||||
@@ -42,6 +45,7 @@ func NewApp(
|
||||
eventBus: bus,
|
||||
plugins: plugins,
|
||||
vault: vaultService,
|
||||
storage: storageService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +252,83 @@ func (a *App) CloseVault() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Storage API ────────────────────────────────────────────
|
||||
|
||||
// ReadPluginSettings returns all settings for a plugin.
|
||||
func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} {
|
||||
if a.storage == nil {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
data, err := a.storage.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// WritePluginSettings writes all settings for a plugin.
|
||||
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
if err := a.storage.WritePluginSettings(pluginID, data); err != nil {
|
||||
log.Printf("[api] WritePluginSettings(%s): %v", pluginID, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReadPluginSetting returns a single setting value.
|
||||
func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
||||
if a.storage == nil {
|
||||
return nil
|
||||
}
|
||||
val, err := a.storage.ReadPluginSetting(pluginID, key)
|
||||
if err != nil {
|
||||
log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// WritePluginSetting writes a single setting value.
|
||||
func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
if err := a.storage.WritePluginSetting(pluginID, key, value); err != nil {
|
||||
log.Printf("[api] WritePluginSetting(%s, %s): %v", pluginID, key, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||
func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||
if a.storage == nil {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
data, err := a.storage.ReadPluginDataJSON(pluginID, name)
|
||||
if err != nil {
|
||||
log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// WritePluginDataJSON writes a named JSON data file for a plugin.
|
||||
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
if err := a.storage.WritePluginDataJSON(pluginID, name, data); err != nil {
|
||||
log.Printf("[api] WritePluginDataJSON(%s, %s): %v", pluginID, name, err)
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ContributionSummary aggregates all contribution types for the frontend.
|
||||
type ContributionSummary struct {
|
||||
Views []contribution.ContributionView `json:"views"`
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package storage provides a safe, namespace-isolated JSON storage API for plugins.
|
||||
// All data is stored within the vault's .verstak directory, scoped per plugin.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
// Storage provides plugin-scoped JSON storage (settings, data, cache).
|
||||
type Storage struct {
|
||||
mu sync.RWMutex
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// New creates a new Storage instance backed by the given vault.
|
||||
func New(v *vault.Vault) *Storage {
|
||||
return &Storage{vault: v}
|
||||
}
|
||||
|
||||
// ─── Plugin ID validation ─────────────────────────────────
|
||||
|
||||
func validatePluginID(pluginID string) error {
|
||||
if pluginID == "" {
|
||||
return fmt.Errorf("plugin ID is empty")
|
||||
}
|
||||
if strings.ContainsAny(pluginID, `/\`) {
|
||||
return fmt.Errorf("plugin ID %q contains path separators", pluginID)
|
||||
}
|
||||
if pluginID == "." || pluginID == ".." {
|
||||
return fmt.Errorf("plugin ID %q is a path traversal reference", pluginID)
|
||||
}
|
||||
cleaned := filepath.Clean(pluginID)
|
||||
if cleaned != pluginID {
|
||||
return fmt.Errorf("plugin ID %q contains path traversal", pluginID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Atomic write helper ──────────────────────────────────
|
||||
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create dir %s: %w", dir, err)
|
||||
}
|
||||
tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
|
||||
if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpFile, path); err != nil {
|
||||
os.Remove(tmpFile) // best-effort cleanup
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Settings API ─────────────────────────────────────────
|
||||
|
||||
// ReadPluginSettings reads all settings for a plugin.
|
||||
// Returns empty map if settings.json does not exist.
|
||||
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginSettingsPath(pluginID)
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read settings for plugin %s: %w", pluginID, err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("corrupt settings.json for plugin %s: %w", pluginID, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WritePluginSettings writes all settings for a plugin atomically.
|
||||
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginSettingsPath(pluginID)
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
|
||||
}
|
||||
return atomicWrite(path, encoded)
|
||||
}
|
||||
|
||||
// ReadPluginSetting reads a single setting key.
|
||||
func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
|
||||
settings, err := s.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, ok := settings[key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// WritePluginSetting writes a single setting key.
|
||||
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
|
||||
settings, err := s.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings[key] = value
|
||||
return s.WritePluginSettings(pluginID, settings)
|
||||
}
|
||||
|
||||
// ─── Data JSON API ────────────────────────────────────────
|
||||
|
||||
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||
func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interface{}, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("data name is empty")
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginDataPath(pluginID)
|
||||
path := filepath.Join(dir, name+".json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read data %s for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("corrupt data file %s.json for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WritePluginDataJSON writes a named JSON data file for a plugin atomically.
|
||||
func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) error {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("data name is empty")
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginDataPath(pluginID)
|
||||
path := filepath.Join(dir, name+".json")
|
||||
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal data %s for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
return atomicWrite(path, encoded)
|
||||
}
|
||||
|
||||
// ─── Cache JSON API ───────────────────────────────────────
|
||||
|
||||
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
|
||||
func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interface{}, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("cache name is empty")
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginCachePath(pluginID)
|
||||
path := filepath.Join(dir, name+".json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read cache %s for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("corrupt cache file %s.json for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WritePluginCacheJSON writes a named JSON cache file for a plugin atomically.
|
||||
func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]interface{}) error {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("cache name is empty")
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginCachePath(pluginID)
|
||||
path := filepath.Join(dir, name+".json")
|
||||
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal cache %s for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
return atomicWrite(path, encoded)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
// newTestVault creates a vault in a temp directory for testing.
|
||||
func newTestVault(t *testing.T) (*vault.Vault, string) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(tmpDir); err != nil {
|
||||
t.Fatalf("failed to create test vault: %v", err)
|
||||
}
|
||||
return v, tmpDir
|
||||
}
|
||||
|
||||
func newTestStorage(t *testing.T) (*Storage, string) {
|
||||
t.Helper()
|
||||
v, dir := newTestVault(t)
|
||||
return New(v), dir
|
||||
}
|
||||
|
||||
// ─── Settings tests ──────────────────────────────────────────
|
||||
|
||||
func TestWriteReadPluginSettings(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"theme": "dark",
|
||||
"lang": "en",
|
||||
"count": float64(42),
|
||||
}
|
||||
|
||||
if err := s.WritePluginSettings("my-plugin", data); err != nil {
|
||||
t.Fatalf("WritePluginSettings: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.ReadPluginSettings("my-plugin")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
|
||||
if got["theme"] != "dark" {
|
||||
t.Errorf("theme = %v, want dark", got["theme"])
|
||||
}
|
||||
if got["lang"] != "en" {
|
||||
t.Errorf("lang = %v, want en", got["lang"])
|
||||
}
|
||||
if got["count"] != float64(42) {
|
||||
t.Errorf("count = %v, want 42", got["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPluginSettings_NotFound(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
got, err := s.ReadPluginSettings("unknown-plugin")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPluginSettings_Corrupt(t *testing.T) {
|
||||
s, dir := newTestStorage(t)
|
||||
|
||||
// Write corrupt JSON into the settings file
|
||||
settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "bad-plugin")
|
||||
os.MkdirAll(settingsDir, 0o755)
|
||||
os.WriteFile(filepath.Join(settingsDir, "settings.json"), []byte("{not json!!"), 0o644)
|
||||
|
||||
_, err := s.ReadPluginSettings("bad-plugin")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt settings.json, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePluginSetting_SingleKey(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
// Write a single key
|
||||
if err := s.WritePluginSetting("my-plugin", "key1", "value1"); err != nil {
|
||||
t.Fatalf("WritePluginSetting: %v", err)
|
||||
}
|
||||
|
||||
// Read it back
|
||||
val, err := s.ReadPluginSetting("my-plugin", "key1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSetting: %v", err)
|
||||
}
|
||||
if val != "value1" {
|
||||
t.Errorf("key1 = %v, want value1", val)
|
||||
}
|
||||
|
||||
// Write another key, first should be preserved
|
||||
if err := s.WritePluginSetting("my-plugin", "key2", float64(99)); err != nil {
|
||||
t.Fatalf("WritePluginSetting: %v", err)
|
||||
}
|
||||
|
||||
settings, err := s.ReadPluginSettings("my-plugin")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
if settings["key1"] != "value1" {
|
||||
t.Errorf("key1 after second write = %v, want value1", settings["key1"])
|
||||
}
|
||||
if settings["key2"] != float64(99) {
|
||||
t.Errorf("key2 = %v, want 99", settings["key2"])
|
||||
}
|
||||
|
||||
// Reading a missing key returns nil
|
||||
val, err = s.ReadPluginSetting("my-plugin", "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSetting: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Errorf("missing key = %v, want nil", val)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data JSON tests ─────────────────────────────────────────
|
||||
|
||||
func TestPluginDataJSON_WriteRead(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{"a", "b", "c"},
|
||||
"meta": map[string]interface{}{"version": float64(1)},
|
||||
}
|
||||
|
||||
if err := s.WritePluginDataJSON("data-plugin", "mydata", data); err != nil {
|
||||
t.Fatalf("WritePluginDataJSON: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.ReadPluginDataJSON("data-plugin", "mydata")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginDataJSON: %v", err)
|
||||
}
|
||||
|
||||
items, ok := got["items"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("items is not []interface{}, it's %T", got["items"])
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Errorf("items len = %d, want 3", len(items))
|
||||
}
|
||||
|
||||
// Ensure separate names don't collide
|
||||
got2, err := s.ReadPluginDataJSON("data-plugin", "other")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginDataJSON other: %v", err)
|
||||
}
|
||||
if len(got2) != 0 {
|
||||
t.Errorf("expected empty map for other, got %v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cache JSON tests ────────────────────────────────────────
|
||||
|
||||
func TestPluginCacheJSON_WriteRead(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"lastSync": "2025-01-01T00:00:00Z",
|
||||
"hitRate": 0.95,
|
||||
}
|
||||
|
||||
if err := s.WritePluginCacheJSON("cache-plugin", "sync-state", data); err != nil {
|
||||
t.Fatalf("WritePluginCacheJSON: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.ReadPluginCacheJSON("cache-plugin", "sync-state")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginCacheJSON: %v", err)
|
||||
}
|
||||
|
||||
if got["lastSync"] != "2025-01-01T00:00:00Z" {
|
||||
t.Errorf("lastSync = %v", got["lastSync"])
|
||||
}
|
||||
|
||||
// Empty read for missing cache
|
||||
got2, err := s.ReadPluginCacheJSON("cache-plugin", "nope")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginCacheJSON nope: %v", err)
|
||||
}
|
||||
if len(got2) != 0 {
|
||||
t.Errorf("expected empty map, got %v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Path traversal tests ────────────────────────────────────
|
||||
|
||||
func TestPathTraversal_Blocked(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
|
||||
traversalIDs := []string{
|
||||
"..",
|
||||
"../evil",
|
||||
"foo/../../bar",
|
||||
"/absolute",
|
||||
`backslash\traverse`,
|
||||
}
|
||||
|
||||
for _, id := range traversalIDs {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
err := s.WritePluginSettings(id, map[string]interface{}{"x": 1})
|
||||
if err == nil {
|
||||
t.Errorf("WritePluginSettings(%q): expected error, got nil", id)
|
||||
}
|
||||
|
||||
_, err = s.ReadPluginSettings(id)
|
||||
if err == nil {
|
||||
t.Errorf("ReadPluginSettings(%q): expected error, got nil", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Empty pluginID should also be rejected
|
||||
err := s.WritePluginSettings("", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Error("WritePluginSettings(\"\"): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Atomic write tests ──────────────────────────────────────
|
||||
|
||||
func TestAtomicWrite(t *testing.T) {
|
||||
s, dir := newTestStorage(t)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"key": "value",
|
||||
"n": float64(123),
|
||||
}
|
||||
|
||||
if err := s.WritePluginSettings("atomic-plugin", data); err != nil {
|
||||
t.Fatalf("WritePluginSettings: %v", err)
|
||||
}
|
||||
|
||||
// Verify no .tmp files remain in the settings directory
|
||||
settingsDir := filepath.Join(dir, "VerstakVault", ".verstak", "plugin-settings", "atomic-plugin")
|
||||
entries, err := os.ReadDir(settingsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".tmp" || (len(e.Name()) > 4 && e.Name()[:4] == ".tmp") {
|
||||
t.Errorf("leftover temp file found: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user