cb9752b1a2
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
)
|
|
|
|
// migrationSaveErrorStore exposes both legacy and already-migrated records,
|
|
// then fails the migration write. Registry.Load must still publish the full
|
|
// List result so boot scans cannot treat valid records as sideloads.
|
|
type migrationSaveErrorStore struct {
|
|
store.Store
|
|
records []*store.Record
|
|
saves int
|
|
}
|
|
|
|
func (s *migrationSaveErrorStore) List() ([]*store.Record, error) {
|
|
return s.records, nil
|
|
}
|
|
|
|
func (s *migrationSaveErrorStore) Save(*store.Record) error {
|
|
s.saves++
|
|
return errors.New("simulated migration save failure")
|
|
}
|
|
|
|
// testManifest returns a minimal valid runtime-managed manifest for tests
|
|
// that only need a store.Record, not a full installed package (see
|
|
// service_test.go's testPackage for that).
|
|
func testManifest(id string) *manifest.Manifest {
|
|
return &manifest.Manifest{
|
|
ID: id,
|
|
APIVersion: 1,
|
|
Version: "1.0.0",
|
|
DisplayName: "Test Plugin",
|
|
Runtime: manifest.Runtime{
|
|
Type: "binary",
|
|
Executables: map[string]string{"linux-amd64": "server/plugin-linux-amd64"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestRegistryLoadPopulatesFromStore(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fsStore := store.NewFSStore(dir)
|
|
if err := fsStore.Save(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
reg := NewRegistry()
|
|
if err := reg.Load(fsStore); err != nil {
|
|
t.Fatalf("Load() unexpected error: %v", err)
|
|
}
|
|
|
|
rec, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatalf("Get() expected record to be present after Load()")
|
|
}
|
|
if rec.ID != "kandev-plugin-slack" {
|
|
t.Fatalf("Get() ID = %q, want %q", rec.ID, "kandev-plugin-slack")
|
|
}
|
|
}
|
|
|
|
func TestRegistryLoadMigratesMissingInstallationIDOnce(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fsStore := store.NewFSStore(dir)
|
|
if err := fsStore.Save(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
reg := NewRegistry()
|
|
if err := reg.Load(fsStore); err != nil {
|
|
t.Fatalf("Load() unexpected error: %v", err)
|
|
}
|
|
|
|
first, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatal("Get() expected record to be present after Load()")
|
|
}
|
|
if first.InstallationID == "" {
|
|
t.Fatal("Load() did not mint an installation id for a legacy record")
|
|
}
|
|
|
|
reloaded, err := fsStore.Get("kandev-plugin-slack")
|
|
if err != nil {
|
|
t.Fatalf("reloaded record: %v", err)
|
|
}
|
|
if reloaded.InstallationID != first.InstallationID {
|
|
t.Fatalf("installation id changed across migration/save: first=%q reloaded=%q", first.InstallationID, reloaded.InstallationID)
|
|
}
|
|
|
|
reg2 := NewRegistry()
|
|
if err := reg2.Load(fsStore); err != nil {
|
|
t.Fatalf("second Load() unexpected error: %v", err)
|
|
}
|
|
second, ok := reg2.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatal("Get() expected record to be present after second Load()")
|
|
}
|
|
if second.InstallationID != first.InstallationID {
|
|
t.Fatalf("installation id was regenerated on replay: first=%q second=%q", first.InstallationID, second.InstallationID)
|
|
}
|
|
}
|
|
|
|
func TestRegistryLoadRetainsRecordsWhenMigrationSaveFails(t *testing.T) {
|
|
legacy := &store.Record{Manifest: *testManifest("kandev-plugin-legacy"), Status: store.StatusDisabled}
|
|
migrated := &store.Record{
|
|
Manifest: *testManifest("kandev-plugin-migrated"),
|
|
Status: store.StatusActive,
|
|
InstallationID: "stable-installation",
|
|
}
|
|
registry := NewRegistry()
|
|
backend := &migrationSaveErrorStore{records: []*store.Record{legacy, migrated}}
|
|
err := registry.Load(backend)
|
|
if err == nil {
|
|
t.Fatal("Load() expected migration error")
|
|
}
|
|
|
|
for _, want := range []*store.Record{legacy, migrated} {
|
|
got, ok := registry.Get(want.ID)
|
|
if !ok {
|
|
t.Fatalf("Load() dropped %q after migration failure", want.ID)
|
|
}
|
|
if got.Status != want.Status {
|
|
t.Fatalf("Load() status for %q = %q, want %q", want.ID, got.Status, want.Status)
|
|
}
|
|
}
|
|
if got, _ := registry.Get(legacy.ID); got.InstallationID != "" {
|
|
t.Fatalf("Load() exposed an unpersisted installation id %q", got.InstallationID)
|
|
}
|
|
|
|
pluginsDir := t.TempDir()
|
|
manifestPath := filepath.Join(pluginsDir, legacy.ID, "1.0.0", manifestFileName)
|
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll() = %v", err)
|
|
}
|
|
if err := os.WriteFile(manifestPath, []byte("id: kandev-plugin-legacy\napi_version: 1\nversion: 1.0.0\ndisplay_name: Legacy\nruntime:\n type: binary\n executables:\n linux-amd64: server/plugin\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile() = %v", err)
|
|
}
|
|
|
|
svc := &Service{store: backend, registry: registry, log: testLogger(t)}
|
|
if err := svc.SetPluginsDir(pluginsDir); err != nil {
|
|
t.Fatalf("SetPluginsDir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
if _, err := svc.approvalLedger().grant("stable-installation", "workspace-1", 1, "digest", nil, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
|
|
t.Fatalf("seed approval: %v", err)
|
|
}
|
|
result := svc.bootScan(context.Background())
|
|
if len(result.Added) != 0 {
|
|
t.Fatalf("boot scan registered an existing record as a sideload: %#v", result.Added)
|
|
}
|
|
if backend.saves != 1 {
|
|
t.Fatalf("boot scan performed %d store saves, want only the failed migration save", backend.saves)
|
|
}
|
|
if _, ok, err := svc.approvalCurrent("stable-installation", "workspace-1"); err != nil || !ok {
|
|
t.Fatalf("boot scan lost an unrelated approval: ok=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestServiceUninstallLegacyRecordAfterInstallationIDMigrationFails(t *testing.T) {
|
|
pluginsDir := t.TempDir()
|
|
fsStore := store.NewFSStore(pluginsDir)
|
|
legacy := &store.Record{
|
|
Manifest: *testManifest("kandev-plugin-legacy"),
|
|
Status: store.StatusDisabled,
|
|
InstallPath: filepath.Join(pluginsDir, "kandev-plugin-legacy", "1.0.0"),
|
|
}
|
|
if err := os.MkdirAll(legacy.InstallPath, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll() = %v", err)
|
|
}
|
|
if err := fsStore.Save(legacy); err != nil {
|
|
t.Fatalf("Save() = %v", err)
|
|
}
|
|
|
|
registry := NewRegistry()
|
|
backend := &migrationSaveErrorStore{Store: fsStore, records: []*store.Record{legacy}}
|
|
if err := registry.Load(backend); err == nil {
|
|
t.Fatal("Load() expected migration error")
|
|
}
|
|
svc := NewService(backend, registry, nil, testLogger(t))
|
|
if err := svc.SetPluginsDir(pluginsDir); err != nil {
|
|
t.Fatalf("SetPluginsDir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
|
|
if err := svc.Uninstall(t.Context(), legacy.ID); err != nil {
|
|
t.Fatalf("Uninstall() legacy record: %v", err)
|
|
}
|
|
if _, err := fsStore.Get(legacy.ID); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("store.Get() after Uninstall() = %v, want store.ErrNotFound", err)
|
|
}
|
|
if _, ok := registry.Get(legacy.ID); ok {
|
|
t.Fatal("registry retained legacy record after Uninstall()")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetMissingReturnsNotOK(t *testing.T) {
|
|
reg := NewRegistry()
|
|
if _, ok := reg.Get("missing"); ok {
|
|
t.Fatalf("Get() expected ok = false for missing id")
|
|
}
|
|
}
|
|
|
|
func TestRegistryAddThenListReturnsRecord(t *testing.T) {
|
|
reg := NewRegistry()
|
|
reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered})
|
|
|
|
list := reg.List()
|
|
if len(list) != 1 {
|
|
t.Fatalf("List() len = %d, want 1", len(list))
|
|
}
|
|
if list[0].ID != "kandev-plugin-slack" {
|
|
t.Fatalf("List()[0].ID = %q, want %q", list[0].ID, "kandev-plugin-slack")
|
|
}
|
|
}
|
|
|
|
func TestRegistryRemoveDeletesRecord(t *testing.T) {
|
|
reg := NewRegistry()
|
|
reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered})
|
|
|
|
reg.Remove("kandev-plugin-slack")
|
|
|
|
if _, ok := reg.Get("kandev-plugin-slack"); ok {
|
|
t.Fatalf("Get() expected record to be removed")
|
|
}
|
|
}
|
|
|
|
func TestRegistrySetStatusUpdatesRecord(t *testing.T) {
|
|
reg := NewRegistry()
|
|
reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered})
|
|
|
|
updated, ok := reg.SetStatus("kandev-plugin-slack", store.StatusActive)
|
|
if !ok {
|
|
t.Fatalf("SetStatus() expected ok = true")
|
|
}
|
|
if updated.Status != store.StatusActive {
|
|
t.Fatalf("SetStatus() returned Status = %q, want %q", updated.Status, store.StatusActive)
|
|
}
|
|
|
|
rec, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatalf("Get() expected record present")
|
|
}
|
|
if rec.Status != store.StatusActive {
|
|
t.Fatalf("Get() Status = %q, want %q", rec.Status, store.StatusActive)
|
|
}
|
|
}
|
|
|
|
func TestRegistrySetStatusMissingReturnsNotOK(t *testing.T) {
|
|
reg := NewRegistry()
|
|
if _, ok := reg.SetStatus("missing", store.StatusActive); ok {
|
|
t.Fatalf("SetStatus() expected ok = false for missing id")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetReturnsIndependentCopy(t *testing.T) {
|
|
reg := NewRegistry()
|
|
autoUpdate := true
|
|
mf := testManifest("kandev-plugin-slack")
|
|
mf.Categories = []string{"chat"}
|
|
mf.RepositoryProviders = []string{"github"}
|
|
mf.ConfigSchema = map[string]any{"nested": map[string]any{"deep": "value"}}
|
|
mf.AgentTools = []manifest.AgentTool{{Name: "tool", Surfaces: []string{"kanban-task"}}}
|
|
reg.Add(&store.Record{
|
|
Manifest: *mf,
|
|
Status: store.StatusRegistered,
|
|
AutoUpdate: &autoUpdate,
|
|
})
|
|
|
|
rec, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatalf("Get() expected ok = true")
|
|
}
|
|
rec.Status = store.StatusActive // mutate the returned copy
|
|
*rec.AutoUpdate = false // mutate the returned pointer copy
|
|
rec.Categories[0] = "mutated" // mutate a returned nested slice element
|
|
rec.RepositoryProviders = append(rec.RepositoryProviders, "gitlab")
|
|
rec.ConfigSchema["nested"].(map[string]any)["deep"] = "mutated"
|
|
rec.AgentTools[0].Surfaces[0] = "office-task"
|
|
rec.Runtime.Executables["linux-amd64"] = "server/mutated"
|
|
|
|
fresh, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatalf("Get() expected ok = true")
|
|
}
|
|
if fresh.Status != store.StatusRegistered {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: Status = %q, want %q", fresh.Status, store.StatusRegistered)
|
|
}
|
|
if fresh.AutoUpdate == nil || !*fresh.AutoUpdate {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: AutoUpdate = %v, want true", fresh.AutoUpdate)
|
|
}
|
|
if fresh.Categories[0] != "chat" || len(fresh.RepositoryProviders) != 1 || fresh.RepositoryProviders[0] != "github" {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: Categories=%v RepositoryProviders=%v", fresh.Categories, fresh.RepositoryProviders)
|
|
}
|
|
if fresh.ConfigSchema["nested"].(map[string]any)["deep"] != "value" {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: ConfigSchema=%v", fresh.ConfigSchema)
|
|
}
|
|
if fresh.AgentTools[0].Surfaces[0] != "kanban-task" {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: AgentTools=%v", fresh.AgentTools)
|
|
}
|
|
if fresh.Runtime.Executables["linux-amd64"] != "server/plugin-linux-amd64" {
|
|
t.Fatalf("mutating a Get() result leaked into the registry: Executables=%v", fresh.Runtime.Executables)
|
|
}
|
|
}
|
|
|
|
func TestRegistrySetRestartCountUpdatesRecord(t *testing.T) {
|
|
reg := NewRegistry()
|
|
reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusActive})
|
|
|
|
updated, ok := reg.SetRestartCount("kandev-plugin-slack", 2)
|
|
if !ok {
|
|
t.Fatalf("SetRestartCount() expected ok = true")
|
|
}
|
|
if updated.RestartCount != 2 {
|
|
t.Fatalf("SetRestartCount() RestartCount = %d, want 2", updated.RestartCount)
|
|
}
|
|
|
|
rec, ok := reg.Get("kandev-plugin-slack")
|
|
if !ok {
|
|
t.Fatalf("Get() expected record present")
|
|
}
|
|
if rec.RestartCount != 2 {
|
|
t.Fatalf("Get() RestartCount = %d, want 2", rec.RestartCount)
|
|
}
|
|
}
|
|
|
|
func TestRegistrySetRestartCountMissingReturnsNotOK(t *testing.T) {
|
|
reg := NewRegistry()
|
|
if _, ok := reg.SetRestartCount("missing", 1); ok {
|
|
t.Fatalf("SetRestartCount() expected ok = false for missing id")
|
|
}
|
|
}
|