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) } }