466 lines
15 KiB
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)
|
|
}
|
|
}
|