Files
kandev/apps/backend/internal/plugins/service_install_test.go
T

374 lines
13 KiB
Go

package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"sync"
"testing"
"github.com/kandev/kandev/internal/plugins/pkgtar"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
)
func TestServiceInstallActivatesOnSuccessfulSpawn(t *testing.T) {
svc, _, rt := newTestService(t)
rec := installTestPlugin(t, svc, "kandev-plugin-slack")
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("Install() did not spawn the plugin via the runtime manager")
}
got, err := svc.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("Get() unexpected error: %v", err)
}
if got.InstallPath == "" {
t.Fatal("Get().InstallPath is empty after Install()")
}
}
func TestServiceInstallDuplicateVersionReturnsErrVersionExists(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
_, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false))
if !errors.Is(err, pkgtar.ErrVersionExists) {
t.Fatalf("Install() duplicate error = %v, want pkgtar.ErrVersionExists", err)
}
}
func TestServiceInstallSpawnFailureLeavesRecordInErrorStatus(t *testing.T) {
svc, fsStore, rt := newTestService(t)
rt.setStartErr("kandev-plugin-slack", errors.New("spawn failed"))
rec, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false))
if err == nil {
t.Fatal("Install() expected an error when the spawn fails")
}
if rec == nil {
t.Fatal("Install() expected a non-nil record even when the spawn fails (package was extracted)")
}
if rec.Status != StatusError {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusError)
}
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
if getErr != nil {
t.Fatalf("store.Get(): %v", getErr)
}
if onDisk.Status != StatusError {
t.Fatalf("store.Get().Status = %q, want %q", onDisk.Status, StatusError)
}
}
// TestServiceInstallOverActivePluginRestartsWithNewVersion pins the fix for
// installing a new version over an already-active plugin: activate's
// "already running" short-circuit previously skipped spawning entirely, so
// the live subprocess kept running the OLD version's binary even though the
// record/install_path now pointed at the new one. Install must stop the old
// process and start the new install_path so the running process matches the
// installed version.
func TestServiceInstallOverActivePluginRestartsWithNewVersion(t *testing.T) {
svc, _, rt := newTestService(t)
rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running
if !rt.Running("kandev-plugin-slack") {
t.Fatal("sanity check: plugin not running after first install")
}
rec2, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false))
if err != nil {
t.Fatalf("second Install() unexpected error: %v", err)
}
if rec2.Version != "1.1.0" {
t.Fatalf("rec2.Version = %q, want %q", rec2.Version, "1.1.0")
}
if rec2.InstallPath == rec1.InstallPath {
t.Fatal("rec2.InstallPath == rec1.InstallPath, want the new version's own install dir")
}
if !rt.stopped("kandev-plugin-slack") {
t.Fatal("Install() over an active plugin did not stop the old runtime process")
}
if got := rt.startCallCount("kandev-plugin-slack"); got != 2 {
t.Fatalf("runtime Start called %d times, want 2 (initial install + restart on reinstall)", got)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("plugin not running after reinstalling over an already-active version")
}
if rec2.Status != StatusActive {
t.Fatalf("rec2.Status = %q, want %q", rec2.Status, StatusActive)
}
}
// failingSaveStore wraps a real store.Store, letting a test arm exactly one
// Save call to fail with a simulated error — used to exercise Install's
// cleanup-on-persist-failure path without a real disk-full/permission
// simulation.
type failingSaveStore struct {
store.Store
mu sync.Mutex
failNext bool
}
func (s *failingSaveStore) Save(rec *store.Record) error {
s.mu.Lock()
fail := s.failNext
s.failNext = false
s.mu.Unlock()
if fail {
return errors.New("simulated save failure")
}
return s.Store.Save(rec)
}
func (s *failingSaveStore) armFailNextSave() {
s.mu.Lock()
s.failNext = true
s.mu.Unlock()
}
// TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData pins the
// fix for a failed upgrade destroying every installed version plus the
// plugin's writable data directory: cleanup on a store.Save failure must
// remove only the freshly extracted version directory, and must restart the
// previous version's process (already stopped to make way for the new
// spawn) rather than leaving the plugin down.
func TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
failing := &failingSaveStore{Store: fsStore}
reg := NewRegistry()
svc := NewService(failing, reg, nil, testLogger(t))
svc.SetPluginsDir(dir)
rt := newFakeRuntime()
svc.SetRuntime(rt)
rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running
if !rt.Running("kandev-plugin-slack") {
t.Fatal("sanity check: plugin not running after first install")
}
// Simulate the old version's writable data directory holding user data.
dataDir := filepath.Join(dir, "kandev-plugin-slack", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("MkdirAll(dataDir): %v", err)
}
marker := filepath.Join(dataDir, "marker.txt")
if err := os.WriteFile(marker, []byte("keep me"), 0o644); err != nil {
t.Fatalf("WriteFile(marker): %v", err)
}
failing.armFailNextSave()
_, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false))
if err == nil {
t.Fatal("Install() expected an error from the simulated Save failure")
}
if _, statErr := os.Stat(rec1.InstallPath); statErr != nil {
t.Fatalf("old version dir %q was removed by cleanup: %v", rec1.InstallPath, statErr)
}
if _, statErr := os.Stat(marker); statErr != nil {
t.Fatalf("plugin data dir marker was removed by cleanup: %v", statErr)
}
newVersionDir := filepath.Join(dir, "kandev-plugin-slack", "1.1.0")
if _, statErr := os.Stat(newVersionDir); !os.IsNotExist(statErr) {
t.Fatalf("new version dir %q still exists after the Save failure: stat err = %v", newVersionDir, statErr)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("the previous version's process was not restarted after the failed upgrade")
}
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
if getErr != nil {
t.Fatalf("store.Get(): %v", getErr)
}
if onDisk.Version != "1.0.0" {
t.Fatalf("store record Version = %q, want %q (the old version's record must survive unchanged)", onDisk.Version, "1.0.0")
}
}
// minKandevVersionPackage builds a valid, runtime-managed plugin tar.gz
// declaring min_kandev_version, for TestServiceInstall_MinKandevVersion*.
func minKandevVersionPackage(t *testing.T, id, version, minKandevVersion string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: %s
display_name: Test Plugin
min_kandev_version: %q
runtime:
type: binary
executables:
%s: server/plugin
`, id, version, minKandevVersion, platformKey)
var buf bytes.Buffer
files := map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}
if err := pkgtartest.WritePackage(&buf, files); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
// TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion pins
// enforcement of manifest.min_kandev_version: once a running kandev version
// is wired via SetKandevVersion, a package that requires a newer kandev
// must be rejected (and its extracted package directory cleaned up) rather
// than installed and left to fail confusingly at spawn time.
func TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
svc.SetKandevVersion("1.0.0")
_, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "2.0.0"))
if err == nil {
t.Fatal("Install() expected an error for a package requiring a newer kandev version")
}
if _, statErr := os.Stat(filepath.Join(dir, "kandev-plugin-slack", "1.0.0")); !os.IsNotExist(statErr) {
t.Fatalf("Install() left the extracted package on disk after rejecting it: stat err = %v", statErr)
}
if _, getErr := svc.Get("kandev-plugin-slack"); getErr == nil {
t.Fatal("Install() registered a record for a package it rejected on min_kandev_version")
}
}
// TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion pins the
// non-blocking side of the same check.
func TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetKandevVersion("2.0.0")
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "1.5.0"))
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
}
func TestServiceInstall_NormalizesReleaseTagBeforeEnforcingMinKandevVersion(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetKandevVersion("v1.0.0")
_, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "2.0.0"))
if err == nil {
t.Fatal("Install() expected a tagged running version below min_kandev_version to be rejected")
}
}
func TestServiceInstall_SkipsMinKandevVersionForNonReleaseBuildVersion(t *testing.T) {
for _, version := range []string{"v1.2.3-4-gabcdef"} {
t.Run(version, func(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetKandevVersion(version)
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "999.0.0"))
if err != nil {
t.Fatalf("Install() with non-release build version %q: %v", version, err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
})
}
}
// TestServiceInstall_NoEnforcementOnDevBuild pins the dev-build escape
// hatch. An un-stamped local build reports the "dev" sentinel, and a
// developer running from source must still be able to install any package —
// exactly as before this check was wired up in production.
//
// The escape hatch is explicit because "dev" is not a release boundary. It
// must skip enforcement regardless of whether the manifest minimum is bare
// or carries the leading `v` accepted for release values.
func TestServiceInstall_NoEnforcementOnDevBuild(t *testing.T) {
for _, minVersion := range []string{"999.0.0", "v1.0.0"} {
t.Run(minVersion, func(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetKandevVersion(DevKandevVersion)
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", minVersion))
if err != nil {
t.Fatalf("Install() unexpected error on a dev build (min_kandev_version %q): %v", minVersion, err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
})
}
}
// TestServiceInstall_NoEnforcementWithoutKandevVersionWired pins the
// no-op default: a Service that never called SetKandevVersion (e.g. every
// other test in this file, and any caller that hasn't wired it yet) must
// keep installing packages that declare min_kandev_version exactly as
// before.
func TestServiceInstall_NoEnforcementWithoutKandevVersionWired(t *testing.T) {
svc, _, _ := newTestService(t)
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "999.0.0"))
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
}
func TestValidateInstallURL_AcceptsHTTPAndHTTPS(t *testing.T) {
for _, raw := range []string{
"https://example.com/plugin.tar.gz",
"http://example.com/plugin.tar.gz",
} {
if err := validateInstallURL(raw); err != nil {
t.Fatalf("validateInstallURL(%q) unexpected error: %v", raw, err)
}
}
}
func TestValidateInstallURL_RejectsNonHTTPScheme(t *testing.T) {
for _, raw := range []string{
"file:///etc/passwd",
"gopher://example.com/plugin",
"ftp://example.com/plugin.tar.gz",
} {
if err := validateInstallURL(raw); err == nil {
t.Fatalf("validateInstallURL(%q) expected error, got nil", raw)
}
}
}
func TestValidateInstallURL_RejectsEmptyHost(t *testing.T) {
if err := validateInstallURL("https:///plugin.tar.gz"); err == nil {
t.Fatal("validateInstallURL() expected error for empty host, got nil")
}
}
func TestValidateInstallURL_RejectsMalformedURL(t *testing.T) {
if err := validateInstallURL("://not-a-url"); err == nil {
t.Fatal("validateInstallURL() expected error for malformed URL, got nil")
}
}
func TestServiceInstallFromURL_RejectsNonHTTPSchemeBeforeAnyRequest(t *testing.T) {
svc, _, _ := newTestService(t)
_, err := svc.InstallFromURL(context.Background(), "file:///etc/passwd")
if err == nil {
t.Fatal("InstallFromURL() expected error for file:// scheme, got nil")
}
}