Files
kandev/apps/backend/internal/plugins/service_test.go
T
João Salavisa bb5a81a396 refactor(secrets): detect absent secret via ErrNotFound sentinel (#1770)
* refactor(secrets): detect absent secret via ErrNotFound sentinel

Replace the "secret not found:" string-prefix matching with an exported
secrets.ErrNotFound sentinel, matched via errors.Is. The store(s) now
return fmt.Errorf("%w: %s", ErrNotFound, id); the rendered message is
byte-identical so logs are unchanged.

Consumers migrated off the prefix:
- internal/plugins/config.go isSecretNotFound (behind host GetSecret/
  DeleteSecret and service.go vault rollback)
- internal/integrations/secretadapter Exists (jira/linear/sentry inherit
  the change through the shared adapter)

Adds a non-Postgres-gated secrets test asserting errors.Is(err,
ErrNotFound) plus message-unchanged coverage; updates the affected test
fakes to wrap the sentinel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(secrets): address review — eager t.Cleanup, faithful fake Delete

- sqlite_store_test: register sqlxDB close via t.Cleanup immediately
  after creation, before the NewMasterKeyProvider/Provide fatal paths
  (per the t.Cleanup-immediately convention).
- secretadapter_test: fakeStore.Delete now returns wrapped
  secrets.ErrNotFound for an absent id, mirroring the real store and
  the fakeSecretRevealer.Delete fake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 01:08:29 +01:00

783 lines
24 KiB
Go

package plugins
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/pkg/pluginsdk"
)
func testLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
if err != nil {
t.Fatalf("new logger: %v", err)
}
return log
}
// fakeRuntime is a controllable PluginRuntime for tests: Start/Stop just
// flip an in-memory "running" flag (recording every call) instead of
// spawning a real subprocess, so Service's install/activate/status-machine
// logic can be tested without internal/plugins/runtime's real go-plugin
// machinery.
type fakeRuntime struct {
mu sync.Mutex
running map[string]bool
startErr map[string]error
restartCounts map[string]int
startCalls []string
stopCalls []string
lastStartCtx context.Context
// blockStarted/blockProceed, when set via blockNextStart, make the very
// next Start call signal blockStarted and then wait on blockProceed
// before continuing — used by concurrency tests to pause a caller
// mid-Start and prove a competing caller is blocked out.
blockStarted chan struct{}
blockProceed chan struct{}
}
func newFakeRuntime() *fakeRuntime {
return &fakeRuntime{
running: map[string]bool{},
startErr: map[string]error{},
restartCounts: map[string]int{},
}
}
// blockNextStart arms a one-shot block on the next Start call: Start closes
// the returned started channel once it is entered, then waits until the
// returned release func is called before returning.
func (r *fakeRuntime) blockNextStart() (started <-chan struct{}, release func()) {
s := make(chan struct{})
p := make(chan struct{})
r.mu.Lock()
r.blockStarted = s
r.blockProceed = p
r.mu.Unlock()
return s, func() { close(p) }
}
func (r *fakeRuntime) Start(ctx context.Context, rec *store.Record, hostFactory func(string) pluginsdk.Host) error {
r.mu.Lock()
r.startCalls = append(r.startCalls, rec.ID)
r.lastStartCtx = ctx
started, proceed := r.blockStarted, r.blockProceed
r.blockStarted, r.blockProceed = nil, nil
r.mu.Unlock()
if started != nil {
close(started)
<-proceed
}
r.mu.Lock()
defer r.mu.Unlock()
if err := r.startErr[rec.ID]; err != nil {
return err
}
_ = hostFactory(rec.ID) // exercise the factory, mirroring the real manager
r.running[rec.ID] = true
return nil
}
func (r *fakeRuntime) Stop(id string) {
r.mu.Lock()
defer r.mu.Unlock()
r.stopCalls = append(r.stopCalls, id)
r.running[id] = false
}
func (r *fakeRuntime) Get(id string) (*pluginsdk.RemotePlugin, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running[id] {
return nil, false
}
return nil, true
}
func (r *fakeRuntime) Ping(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running[id] {
return fmt.Errorf("not running")
}
return nil
}
func (r *fakeRuntime) Running(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.running[id]
}
func (r *fakeRuntime) RestartCount(id string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.restartCounts[id]
}
func (r *fakeRuntime) StopAll() {
r.mu.Lock()
defer r.mu.Unlock()
for id := range r.running {
r.running[id] = false
}
}
func (r *fakeRuntime) setStartErr(id string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.startErr[id] = err
}
func (r *fakeRuntime) startCallCount(id string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, got := range r.startCalls {
if got == id {
n++
}
}
return n
}
func (r *fakeRuntime) startCtx() context.Context {
r.mu.Lock()
defer r.mu.Unlock()
return r.lastStartCtx
}
func (r *fakeRuntime) stopped(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, got := range r.stopCalls {
if got == id {
return true
}
}
return false
}
// fakeSecretRevealer is a minimal in-memory SecretVault for tests.
type fakeSecretRevealer struct {
mu sync.Mutex
values map[string]string
}
func newFakeSecretRevealer() *fakeSecretRevealer {
return &fakeSecretRevealer{values: map[string]string{}}
}
func (v *fakeSecretRevealer) set(ref, value string) {
v.mu.Lock()
defer v.mu.Unlock()
v.values[ref] = value
}
func (v *fakeSecretRevealer) Reveal(_ context.Context, ref string) (string, error) {
v.mu.Lock()
defer v.mu.Unlock()
value, ok := v.values[ref]
if !ok {
return "", fmt.Errorf("%w: %s", secrets.ErrNotFound, ref)
}
return value, nil
}
func (v *fakeSecretRevealer) Set(_ context.Context, id, _, value string) error {
v.mu.Lock()
defer v.mu.Unlock()
v.values[id] = value
return nil
}
func (v *fakeSecretRevealer) Delete(_ context.Context, id string) error {
v.mu.Lock()
defer v.mu.Unlock()
if _, ok := v.values[id]; !ok {
return fmt.Errorf("%w: %s", secrets.ErrNotFound, id)
}
delete(v.values, id)
return nil
}
func (v *fakeSecretRevealer) ListIDs(_ context.Context) ([]string, error) {
v.mu.Lock()
defer v.mu.Unlock()
ids := make([]string, 0, len(v.values))
for id := range v.values {
ids = append(ids, id)
}
return ids, nil
}
func (v *fakeSecretRevealer) get(id string) (string, bool) {
v.mu.Lock()
defer v.mu.Unlock()
value, ok := v.values[id]
return value, ok
}
// testPackage builds a valid, runtime-managed plugin tar.gz for the CURRENT
// host platform (so pkgtar.Install's platform check passes), with a
// capabilities.events subscription and (optionally) a UI bundle.
func testPackage(t *testing.T, id, version string, withUIBundle bool) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: %s
display_name: Test Plugin
capabilities:
events: ["task.*"]
state: true
secrets: true
runtime:
type: binary
executables:
%s: server/plugin
`, id, version, platformKey)
if withUIBundle {
manifestYAML += "ui:\n bundle: \"/ui/bundle.js\"\n styles: [\"/ui/style.css\"]\n"
}
var buf bytes.Buffer
files := map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}
if withUIBundle {
files["ui/bundle.js"] = []byte("export default {};")
files["ui/style.css"] = []byte("body{}")
}
if err := pkgtartest.WritePackage(&buf, files); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
// newTestService wires a Service against a real FSStore rooted at a temp
// plugins dir, a fresh Registry, and a fakeRuntime — mirroring what Provide
// does, minus the real runtime.Manager.
func newTestService(t *testing.T) (*Service, *store.FSStore, *fakeRuntime) {
t.Helper()
svc, _, fsStore, rt := newTestServiceWithDir(t)
return svc, fsStore, rt
}
func newTestServiceWithDir(t *testing.T) (*Service, string, *store.FSStore, *fakeRuntime) {
t.Helper()
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
reg := NewRegistry()
svc := NewService(fsStore, reg, nil, testLogger(t))
svc.SetPluginsDir(dir)
rt := newFakeRuntime()
svc.SetRuntime(rt)
return svc, dir, fsStore, rt
}
func installTestPlugin(t *testing.T, svc *Service, id string) *store.Record {
t.Helper()
rec, err := svc.Install(context.Background(), testPackage(t, id, "1.0.0", false))
if err != nil {
t.Fatalf("Install(%q): %v", id, err)
}
return rec
}
func TestServiceListReturnsInstalledPlugins(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
installTestPlugin(t, svc, "kandev-plugin-jira")
list := svc.List()
if len(list) != 2 {
t.Fatalf("List() len = %d, want 2", len(list))
}
}
func TestServiceGetMissingReturnsNotFound(t *testing.T) {
svc, _, _ := newTestService(t)
if _, err := svc.Get("missing"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Get() error = %v, want store.ErrNotFound", err)
}
}
func TestServiceUpdateConfigPersists(t *testing.T) {
svc, fsStore, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-slack", map[string]any{"default_channel": "#dev"}); err != nil {
t.Fatalf("UpdateConfig() unexpected error: %v", err)
}
cfg, err := fsStore.GetConfig("kandev-plugin-slack")
if err != nil {
t.Fatalf("GetConfig() unexpected error: %v", err)
}
if cfg["default_channel"] != "#dev" {
t.Fatalf("GetConfig() default_channel = %v, want %q", cfg["default_channel"], "#dev")
}
}
func TestServiceUpdateConfigMissingReturnsNotFound(t *testing.T) {
svc, _, _ := newTestService(t)
err := svc.UpdateConfig(context.Background(), "missing", map[string]any{"a": "b"})
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("UpdateConfig() error = %v, want store.ErrNotFound", err)
}
}
func TestServiceUninstallStopsRuntimeRemovesPackageAndRecord(t *testing.T) {
svc, fsStore, rt := newTestService(t)
rec := installTestPlugin(t, svc, "kandev-plugin-slack")
installDir := filepath.Dir(rec.InstallPath) // .../plugins/kandev-plugin-slack
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall() unexpected error: %v", err)
}
if !rt.stopped("kandev-plugin-slack") {
t.Fatal("Uninstall() did not stop the runtime process")
}
if _, err := svc.Get("kandev-plugin-slack"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Get() after Uninstall() error = %v, want store.ErrNotFound", err)
}
if _, err := fsStore.Get("kandev-plugin-slack"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("store.Get() after Uninstall() error = %v, want store.ErrNotFound", err)
}
if _, err := os.Stat(installDir); !os.IsNotExist(err) {
t.Fatalf("expected the extracted package dir to be removed, stat err = %v", err)
}
}
// TestServiceUninstallDeletesPluginState pins the fix for the plugin_state
// leak on Uninstall: removing a plugin previously deleted its file tree and
// registry record but never its plugin_state rows, so a reinstalled (or
// id-reused) plugin silently inherited stale state from a previous install.
func TestServiceUninstallDeletesPluginState(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetState(newTestStateStore(t))
installTestPlugin(t, svc, "kandev-plugin-slack")
ctx := context.Background()
if err := svc.StateStore().Set(ctx, "kandev-plugin-slack", "instance", "", "install_id", json.RawMessage(`"abc"`)); err != nil {
t.Fatalf("seed plugin_state: %v", err)
}
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall() unexpected error: %v", err)
}
entries, err := svc.StateStore().List(ctx, "kandev-plugin-slack", "instance", "")
if err != nil {
t.Fatalf("List() after Uninstall(): %v", err)
}
if len(entries) != 0 {
t.Fatalf("plugin_state entries after Uninstall() = %d, want 0 (state should be deleted)", len(entries))
}
}
func TestServiceUninstallMissingReturnsNotFound(t *testing.T) {
svc, _, _ := newTestService(t)
if err := svc.Uninstall(context.Background(), "missing"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Uninstall() error = %v, want store.ErrNotFound", err)
}
}
func TestServiceSetStatusInvalidTransitionRejected(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack") // already active
// active -> registered is not a legal single-hop edge.
err := svc.SetStatus("kandev-plugin-slack", StatusRegistered)
var invalidErr *ErrInvalidTransition
if !errors.As(err, &invalidErr) {
t.Fatalf("SetStatus() error = %v, want *ErrInvalidTransition", err)
}
}
func TestServiceSetStatusIntoUninstalledRejected(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
err := svc.SetStatus("kandev-plugin-slack", StatusUninstalled)
var invalidErr *ErrInvalidTransition
if !errors.As(err, &invalidErr) {
t.Fatalf("SetStatus() error = %v, want *ErrInvalidTransition (use Uninstall instead)", err)
}
}
func TestServiceEnableIsIdempotentWhenAlreadyActive(t *testing.T) {
svc, _, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Enable("kandev-plugin-slack"); err != nil {
t.Fatalf("Enable() on an already-active plugin: %v", err)
}
if got := rt.startCallCount("kandev-plugin-slack"); got != 1 {
t.Fatalf("runtime Start called %d times, want 1 (Enable on an active plugin must be a no-op)", got)
}
}
func TestServiceDisableFromActiveStopsRuntime(t *testing.T) {
svc, _, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("Disable() unexpected error: %v", err)
}
got, _ := svc.Get("kandev-plugin-slack")
if got.Status != StatusDisabled {
t.Fatalf("Get() Status = %q, want %q", got.Status, StatusDisabled)
}
if !rt.stopped("kandev-plugin-slack") {
t.Fatal("Disable() did not stop the runtime process")
}
}
func TestServiceDisableIsIdempotentWhenAlreadyDisabled(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("first Disable(): %v", err)
}
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("second Disable() expected no error (idempotent), got %v", err)
}
}
func TestServiceDisabledCanReEnableAndRespawns(t *testing.T) {
svc, _, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("Disable(): %v", err)
}
if err := svc.Enable("kandev-plugin-slack"); err != nil {
t.Fatalf("re-Enable() unexpected error: %v", err)
}
got, _ := svc.Get("kandev-plugin-slack")
if got.Status != StatusActive {
t.Fatalf("Get() Status = %q, want %q", got.Status, StatusActive)
}
if want := 2; rt.startCallCount("kandev-plugin-slack") != want {
t.Fatalf("runtime Start called %d times, want %d (install + re-enable)", rt.startCallCount("kandev-plugin-slack"), want)
}
}
// TestServiceEnable_ConcurrentCallsForSameID_OnlyOneActivationNoError proves
// Enable is guarded by a per-plugin lock: two near-simultaneous Enable calls
// for the same disabled plugin id must not both pass the StatusActive
// idempotency check and race into activate. Without the lock, the second
// call's SetStatus(Active) races the first, and the loser observes
// *ErrInvalidTransition ("active -> active") even though the plugin is
// correctly active by the time both calls return.
func TestServiceEnable_ConcurrentCallsForSameID_OnlyOneActivationNoError(t *testing.T) {
svc, _, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("Disable(): %v", err)
}
started, release := rt.blockNextStart()
firstErr := make(chan error, 1)
go func() {
firstErr <- svc.Enable("kandev-plugin-slack")
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("first Enable() never reached the blocked runtime Start call")
}
secondErr := make(chan error, 1)
go func() {
secondErr <- svc.Enable("kandev-plugin-slack")
}()
// The second Enable() must still be blocked behind the per-plugin lock:
// give it a bounded window to (incorrectly) complete, and fail if it does.
select {
case err := <-secondErr:
t.Fatalf("second Enable() completed (err=%v) while the first was still starting — Enable is not per-plugin lock-guarded", err)
case <-time.After(200 * time.Millisecond):
}
release()
select {
case err := <-firstErr:
if err != nil {
t.Fatalf("first Enable() unexpected error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("first Enable() never returned after release")
}
select {
case err := <-secondErr:
if err != nil {
t.Fatalf("second Enable() unexpected error (must be a no-op observing the now-active status): %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("second Enable() never returned after the first released the lock")
}
// Install's initial activate() already called Start once; this Enable
// (after the earlier Disable) must add exactly one more — never two —
// proving the second concurrent Enable() did not also spawn.
if got, want := rt.startCallCount("kandev-plugin-slack"), 2; got != want {
t.Fatalf("runtime Start called %d times for kandev-plugin-slack, want exactly %d (no double activation)", got, want)
}
rec, err := svc.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("Get() unexpected error: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Status = %q, want %q", rec.Status, StatusActive)
}
}
// TestServiceActivatePassesABoundedStartContext pins the fix for bounded
// startup: activate previously passed context.Background() straight through
// to runtime.Start, so a hung plugin binary could block Enable/Install
// indefinitely (up to go-plugin's own default). activate must instead pass a
// context with a deadline, so a caller can observe/rely on the bound even if
// a future runtime.Manager becomes context-aware for the handshake itself.
func TestServiceActivatePassesABoundedStartContext(t *testing.T) {
svc, _, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
ctx := rt.startCtx()
if ctx == nil {
t.Fatal("runtime.Start() was never called")
}
if _, ok := ctx.Deadline(); !ok {
t.Fatal("activate() passed a context with no deadline to runtime.Start(), want a bounded context.WithTimeout")
}
}
func TestServiceHandleStatusChangeUnhealthyTransitionsToErrorAndRefreshesDeliverer(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
svc.handleStatusChange("kandev-plugin-slack", false)
got, _ := svc.Get("kandev-plugin-slack")
if got.Status != StatusError {
t.Fatalf("Status after unhealthy transition = %q, want %q", got.Status, StatusError)
}
if deliverer.refreshCount != 1 {
t.Fatalf("Refresh() call count = %d, want 1", deliverer.refreshCount)
}
if len(deliverer.flushedIDs) != 0 {
t.Fatalf("Flush() should not be called on a degrade transition, got %v", deliverer.flushedIDs)
}
}
func TestServiceHandleStatusChangeHealthyRecoversAndFlushesDeliverer(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
svc.handleStatusChange("kandev-plugin-slack", false) // degrade first
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
svc.handleStatusChange("kandev-plugin-slack", true)
got, _ := svc.Get("kandev-plugin-slack")
if got.Status != StatusActive {
t.Fatalf("Status after recovery = %q, want %q", got.Status, StatusActive)
}
if len(deliverer.flushedIDs) != 1 || deliverer.flushedIDs[0] != "kandev-plugin-slack" {
t.Fatalf("Flush() calls = %v, want [kandev-plugin-slack]", deliverer.flushedIDs)
}
}
func TestServiceHandleStatusChangePersistsRestartCountBestEffort(t *testing.T) {
svc, fsStore, rt := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
rt.mu.Lock()
rt.restartCounts["kandev-plugin-slack"] = 3
rt.mu.Unlock()
svc.handleStatusChange("kandev-plugin-slack", false)
got, _ := svc.Get("kandev-plugin-slack")
if got.RestartCount != 3 {
t.Fatalf("Get().RestartCount = %d, want 3", got.RestartCount)
}
onDisk, err := fsStore.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("store.Get(): %v", err)
}
if onDisk.RestartCount != 3 {
t.Fatalf("store.Get().RestartCount = %d, want 3", onDisk.RestartCount)
}
}
// fakeDeliverer records Refresh/Flush calls so tests can assert the
// Service -> Deliverer attach-point contract without depending on the real
// delivery package.
type fakeDeliverer struct {
refreshCount int
flushedIDs []string
}
func (f *fakeDeliverer) Refresh() { f.refreshCount++ }
func (f *fakeDeliverer) Flush(pluginID string) { f.flushedIDs = append(f.flushedIDs, pluginID) }
func TestServiceInstallNotifiesDelivererRefresh(t *testing.T) {
svc, _, _ := newTestService(t)
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
installTestPlugin(t, svc, "kandev-plugin-slack")
if deliverer.refreshCount != 1 {
t.Fatalf("Refresh() call count = %d, want 1", deliverer.refreshCount)
}
}
func TestServiceUninstallNotifiesDelivererRefresh(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall(): %v", err)
}
if deliverer.refreshCount != 1 {
t.Fatalf("Refresh() call count after Uninstall() = %d, want 1", deliverer.refreshCount)
}
}
func TestServiceWithoutDelivererDoesNotPanic(t *testing.T) {
svc, _, _ := newTestService(t)
// No SetDeliverer call — Install/SetStatus/Uninstall must tolerate a
// nil deliverer (delivery not wired yet, e.g. in unit tests or before
// backendapp attaches it).
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("Disable(): %v", err)
}
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall(): %v", err)
}
}
func TestServiceDelivererAccessorReturnsAttached(t *testing.T) {
svc, _, _ := newTestService(t)
if svc.Deliverer() != nil {
t.Fatalf("Deliverer() = %v, want nil before SetDeliverer", svc.Deliverer())
}
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
if svc.Deliverer() != Deliverer(deliverer) {
t.Fatalf("Deliverer() did not return the attached deliverer")
}
}
func TestServiceRegistryAccessorReturnsSameRegistry(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
reg := NewRegistry()
svc := NewService(fsStore, reg, nil, testLogger(t))
if svc.Registry() != reg {
t.Fatalf("Registry() did not return the injected registry instance")
}
}
func TestServiceRevealSecretWithoutVaultReturnsError(t *testing.T) {
svc, _, _ := newTestService(t)
if _, err := svc.RevealSecret(context.Background(), "some-ref"); err == nil {
t.Fatal("RevealSecret() expected error when no vault configured, got nil")
}
}
func TestServiceRevealSecretResolvesThroughVault(t *testing.T) {
svc, _, _ := newTestService(t)
vault := newFakeSecretRevealer()
vault.set("my-secret-ref", "s3cr3t")
svc.SetSecrets(vault)
got, err := svc.RevealSecret(context.Background(), "my-secret-ref")
if err != nil {
t.Fatalf("RevealSecret() unexpected error: %v", err)
}
if got != "s3cr3t" {
t.Fatalf("RevealSecret() = %q, want %q", got, "s3cr3t")
}
}
func TestServiceActiveUIPluginsFiltersByStatusAndBundle(t *testing.T) {
svc, _, _ := newTestService(t)
if _, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-with-ui", "1.0.0", true)); err != nil {
t.Fatalf("Install(with bundle): %v", err)
}
// Active but no bundle declared — must be excluded.
installTestPlugin(t, svc, "kandev-plugin-no-ui")
active := svc.ActiveUIPlugins()
if len(active) != 1 {
t.Fatalf("ActiveUIPlugins() len = %d, want 1: %+v", len(active), active)
}
if active[0].ID != "kandev-plugin-with-ui" {
t.Fatalf("ActiveUIPlugins()[0].ID = %q, want %q", active[0].ID, "kandev-plugin-with-ui")
}
}
func TestServiceStartActivePluginsSpawnsOnlyActiveManagedNotAlreadyRunning(t *testing.T) {
svc, dir, fsStore, _ := newTestServiceWithDir(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
// Simulate a fresh boot: the registry is reloaded from disk (status
// "active" persisted from the previous run), but the runtime manager
// has no live process yet.
reg2 := NewRegistry()
if err := reg2.Load(fsStore); err != nil {
t.Fatalf("Load(): %v", err)
}
svc2 := NewService(fsStore, reg2, nil, testLogger(t))
svc2.SetPluginsDir(dir)
rt2 := newFakeRuntime()
svc2.SetRuntime(rt2)
svc2.StartActivePlugins(context.Background())
if !rt2.Running("kandev-plugin-slack") {
t.Fatal("StartActivePlugins() did not spawn the active plugin")
}
}