verstak-desktop/internal/core/plugin/lifecycle_test.go

466 lines
15 KiB
Go

package plugin
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/verstak/verstak-desktop/internal/core/capability"
)
// registerCoreCapabilities registers the desktop's core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper()
if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
t.Fatalf("failed to register core capabilities: %v", err)
}
}
// createTempPluginWithManifest creates a plugin directory with a custom manifest JSON.
func createTempPluginWithManifest(t *testing.T, dir, id, manifestJSON string) string {
t.Helper()
pluginDir := filepath.Join(dir, id)
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifestJSON), 0644); err != nil {
t.Fatal(err)
}
return pluginDir
}
// TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins verifies that when core
// capabilities are registered before plugin discovery, a plugin that requires
// verstak/core/plugin-manager/v1 sees it as available (CheckRequired returns empty).
func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
dir := t.TempDir()
// Create a plugin that requires the core plugin-manager capability.
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.core",
"name": "Core Cap Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.core.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.core", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
// Register core capabilities, then check required.
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
missing := reg.CheckRequired(plugins[0].Manifest.Requires)
if len(missing) != 0 {
t.Errorf("expected no missing required capabilities, got: %v", missing)
}
}
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability.
func TestLifecycle_MissingRequiredCapability(t *testing.T) {
dir := t.TempDir()
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.missing",
"name": "Missing Cap Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.missing.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.missing", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
// Registry WITHOUT core capabilities.
reg := capability.NewRegistry()
missing := reg.CheckRequired(plugins[0].Manifest.Requires)
if len(missing) != 1 {
t.Fatalf("expected 1 missing capability, got %d: %v", len(missing), missing)
}
if missing[0] != "verstak/core/plugin-manager/v1" {
t.Errorf("expected missing 'verstak/core/plugin-manager/v1', got %q", missing[0])
}
// Simulate lifecycle: set status to missing-required-capability.
plugins[0].Status = StatusMissingRequiredCapability
if plugins[0].Status != StatusMissingRequiredCapability {
t.Errorf("expected status %q, got %q", StatusMissingRequiredCapability, plugins[0].Status)
}
}
// TestLifecycle_MissingOptionalCapability_DEGRADED verifies that when required
// capabilities are met but an optional capability is missing, the plugin status
// becomes degraded.
func TestLifecycle_MissingOptionalCapability_DEGRADED(t *testing.T) {
dir := t.TempDir()
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.degraded",
"name": "Degraded Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.degraded.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"optionalRequires": ["verstak/core/vault/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.degraded", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
// Required capabilities should be satisfied.
missingRequired := reg.CheckRequired(plugins[0].Manifest.Requires)
if len(missingRequired) != 0 {
t.Errorf("expected no missing required capabilities, got: %v", missingRequired)
}
// Optional capability vault/v1 is NOT registered.
missingOptional := reg.CheckRequired(plugins[0].Manifest.OptionalRequires)
if len(missingOptional) != 1 {
t.Fatalf("expected 1 missing optional capability, got %d: %v", len(missingOptional), missingOptional)
}
if missingOptional[0] != "verstak/core/vault/v1" {
t.Errorf("expected missing optional 'verstak/core/vault/v1', got %q", missingOptional[0])
}
// Simulate lifecycle: required OK + optional missing => degraded.
plugins[0].Status = StatusDegraded
if plugins[0].Status != StatusDegraded {
t.Errorf("expected status %q, got %q", StatusDegraded, plugins[0].Status)
}
}
// TestLifecycle_AllCapabilitiesResolved_LOADED verifies that when all required
// and optional capabilities are registered, the plugin status becomes loaded.
func TestLifecycle_AllCapabilitiesResolved_LOADED(t *testing.T) {
dir := t.TempDir()
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.loaded",
"name": "Loaded Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.loaded.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"optionalRequires": ["verstak/core/vault/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.loaded", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
// Also register the vault capability so optional is satisfied.
if err := reg.Register("verstak-vault", []string{"verstak/core/vault/v1"}); err != nil {
t.Fatalf("failed to register vault capability: %v", err)
}
// Required capabilities satisfied.
missingRequired := reg.CheckRequired(plugins[0].Manifest.Requires)
if len(missingRequired) != 0 {
t.Errorf("expected no missing required capabilities, got: %v", missingRequired)
}
// Optional capabilities satisfied.
missingOptional := reg.CheckRequired(plugins[0].Manifest.OptionalRequires)
if len(missingOptional) != 0 {
t.Errorf("expected no missing optional capabilities, got: %v", missingOptional)
}
// All resolved => loaded.
plugins[0].Status = StatusLoaded
if plugins[0].Status != StatusLoaded {
t.Errorf("expected status %q, got %q", StatusLoaded, plugins[0].Status)
}
}
// TestLifecycle_ReloadPlugins_DoesNotDuplicateCapabilities verifies that after
// UnregisterAll + re-registering core capabilities, registering the same
// plugin capabilities again returns an error (no silent duplication).
func TestLifecycle_ReloadPlugins_DoesNotDuplicateCapabilities(t *testing.T) {
dir := t.TempDir()
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.reload",
"name": "Reload Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.reload.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.reload", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
reg := capability.NewRegistry()
// First lifecycle: register core + plugin capabilities.
registerCoreCapabilities(t, reg)
if err := reg.Register(plugins[0].Manifest.ID, plugins[0].Manifest.Provides); err != nil {
t.Fatalf("first register of plugin capabilities failed: %v", err)
}
// Simulate reload: UnregisterAll + re-register core.
reg.UnregisterAll()
registerCoreCapabilities(t, reg)
// Re-register plugin capabilities — should succeed now (map was cleared).
if err := reg.Register(plugins[0].Manifest.ID, plugins[0].Manifest.Provides); err != nil {
t.Fatalf("re-register of plugin capabilities after UnregisterAll failed: %v", err)
}
// But registering the SAME capability a second time (without clearing) must fail.
if err := reg.Register(plugins[0].Manifest.ID, plugins[0].Manifest.Provides); err == nil {
t.Error("expected error when registering duplicate capability, got nil")
}
// Also verify core capabilities cannot be silently duplicated.
if err := reg.Register("another-plugin", []string{"verstak/core/plugin-manager/v1"}); err == nil {
t.Error("expected error when registering duplicate core capability, got nil")
}
}
// TestLifecycle_DisabledPlugin verifies that a disabled plugin does NOT register
// its capabilities, but remains in the plugin list.
func TestLifecycle_DisabledPlugin(t *testing.T) {
dir := t.TempDir()
// Create a disabled plugin (Enabled=false via custom manifest).
manifest := `{
"schemaVersion": 1,
"id": "test.lifecycle.disabled",
"name": "Disabled Test",
"version": "1.0.0",
"apiVersion": "1.0",
"provides": ["test.lifecycle.disabled.cap1"],
"requires": ["verstak/core/plugin-manager/v1"],
"permissions": ["vault.read"]
}`
createTempPluginWithManifest(t, dir, "test.lifecycle.disabled", manifest)
plugins, errs := DiscoverPlugins([]string{dir})
if len(errs) > 0 {
t.Fatalf("unexpected discovery errors: %v", errs)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
// Simulate lifecycle: plugin is disabled.
plugins[0].Enabled = false
plugins[0].Status = StatusDisabled
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
// Disabled plugin should NOT register its capabilities.
if !plugins[0].Enabled {
// Skip registration — this is the expected lifecycle behavior.
} else {
if err := reg.Register(plugins[0].Manifest.ID, plugins[0].Manifest.Provides); err != nil {
t.Fatalf("failed to register capabilities for enabled plugin: %v", err)
}
}
// Verify the plugin's capabilities are NOT in the registry.
for _, capName := range plugins[0].Manifest.Provides {
if reg.Has(capName) {
t.Errorf("disabled plugin capability %q should NOT be registered", capName)
}
}
// Plugin is still in the list.
if plugins[0].Status != StatusDisabled {
t.Errorf("expected status %q, got %q", StatusDisabled, plugins[0].Status)
}
if plugins[0].Manifest.ID != "test.lifecycle.disabled" {
t.Errorf("expected plugin ID 'test.lifecycle.disabled', got %q", plugins[0].Manifest.ID)
}
}
func TestResolveLifecycleResolvesRequiredCapabilitiesRegardlessOfDiscoveryOrder(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "consumer.plugin",
Provides: []string{"consumer.capability"},
Requires: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "provider.plugin",
Provides: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
if plugins[1].Status != StatusLoaded {
t.Fatalf("provider status = %q, want %q; error=%q", plugins[1].Status, StatusLoaded, plugins[1].Error)
}
if !reg.Has("consumer.capability") || !reg.Has("provider.capability") {
t.Fatalf("resolved capabilities = %#v, want provider and consumer", reg.Available())
}
}
func TestResolveLifecycleDoesNotExposeCapabilitiesFromUnresolvedPlugin(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "dependent.plugin",
Provides: []string{"dependent.capability"},
Requires: []string{"unresolved.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "unresolved.plugin",
Provides: []string{"unresolved.capability"},
Requires: []string{"missing.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
for _, p := range plugins {
if p.Status != StatusMissingRequiredCapability {
t.Fatalf("%s status = %q, want %q; error=%q", p.Manifest.ID, p.Status, StatusMissingRequiredCapability, p.Error)
}
}
if reg.Has("unresolved.capability") || reg.Has("dependent.capability") {
t.Fatalf("unresolved capabilities leaked into registry: %#v", reg.Available())
}
}
func TestResolveLifecycleEvaluatesOptionalCapabilitiesAfterProvidersLoad(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "optional.consumer",
Provides: []string{"consumer.capability"},
OptionalRequires: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "optional.provider",
Provides: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("optional consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
}