cb9752b1a2
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
678 lines
25 KiB
Go
678 lines
25 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
goruntime "runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func TestServiceInstallUpgradeReviewsExistingCapabilityApprovals(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks", "messages"))
|
|
if err != nil {
|
|
t.Fatalf("install initial plugin: %v", err)
|
|
}
|
|
originalDigest := ManifestCapabilityDigest(rec1.Manifest)
|
|
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, originalDigest, []string{"host.v2.read:tasks", "host.v2.read:messages"}, "human", "grant", "audit-1"); err != nil {
|
|
t.Fatalf("grant initial approval: %v", err)
|
|
}
|
|
|
|
rec2, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "tasks"))
|
|
if err != nil {
|
|
t.Fatalf("upgrade plugin: %v", err)
|
|
}
|
|
if rec2.InstallationID != rec1.InstallationID {
|
|
t.Fatalf("installation id changed on upgrade: %q -> %q", rec1.InstallationID, rec2.InstallationID)
|
|
}
|
|
|
|
row, ok, err := svc.approvalCurrent(rec1.InstallationID, "ws-1")
|
|
if err != nil || !ok {
|
|
t.Fatalf("approvalCurrent after upgrade: ok=%v err=%v", ok, err)
|
|
}
|
|
if row.Revision != 2 {
|
|
t.Fatalf("revision after upgrade = %d, want 2", row.Revision)
|
|
}
|
|
if row.ManifestDigest != ManifestCapabilityDigest(rec2.Manifest) {
|
|
t.Fatalf("manifest digest after upgrade = %q, want %q", row.ManifestDigest, ManifestCapabilityDigest(rec2.Manifest))
|
|
}
|
|
if got, want := row.CapabilityIDs, []string{"host.v2.read:tasks"}; !equalStrings(got, want) {
|
|
t.Fatalf("capabilities after narrowing upgrade = %#v, want %#v", got, want)
|
|
}
|
|
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 3, row.ManifestDigest, []string{"host.v2.read:tasks"}, "human", "renew", "audit-2"); err != nil {
|
|
t.Fatalf("renew approval: %v", err)
|
|
}
|
|
rec3, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.2.0", "tasks", "messages"))
|
|
if err != nil {
|
|
t.Fatalf("rollback-equivalent install: %v", err)
|
|
}
|
|
if rec3.InstallationID != rec1.InstallationID {
|
|
t.Fatalf("installation id changed on rollback-equivalent replacement: %q -> %q", rec1.InstallationID, rec3.InstallationID)
|
|
}
|
|
row, ok, err = svc.approvalCurrent(rec1.InstallationID, "ws-1")
|
|
if err != nil || !ok {
|
|
t.Fatalf("approvalCurrent after rollback-equivalent replacement: ok=%v err=%v", ok, err)
|
|
}
|
|
if row.Revision != 4 {
|
|
t.Fatalf("revision after rollback-equivalent replacement = %d, want 4", row.Revision)
|
|
}
|
|
if got, want := row.CapabilityIDs, []string{"host.v2.read:tasks"}; !equalStrings(got, want) {
|
|
t.Fatalf("capabilities after rollback-equivalent replacement = %#v, want %#v", got, want)
|
|
}
|
|
file, err := svc.approvalLedger().load()
|
|
if err != nil {
|
|
t.Fatalf("load approvals: %v", err)
|
|
}
|
|
if len(file.Events) != 4 || file.Events[1].Type != CapabilityApprovalEventUpgradeReview ||
|
|
file.Events[1].BeforeDigest != originalDigest || file.Events[1].AfterDigest != ManifestCapabilityDigest(rec2.Manifest) ||
|
|
file.Events[2].Type != CapabilityApprovalEventGrant || file.Events[3].Type != CapabilityApprovalEventUpgradeReview ||
|
|
file.Events[3].BeforeDigest != ManifestCapabilityDigest(rec2.Manifest) || file.Events[3].AfterDigest != ManifestCapabilityDigest(rec3.Manifest) {
|
|
t.Fatalf("approval events after upgrade = %#v", file.Events)
|
|
}
|
|
}
|
|
|
|
func TestServiceInstallUpgradeReviewFailureRestartsPreviousRuntime(t *testing.T) {
|
|
svc, _, rt := newTestService(t)
|
|
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks"))
|
|
if err != nil {
|
|
t.Fatalf("install initial plugin: %v", err)
|
|
}
|
|
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, ManifestCapabilityDigest(rec1.Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
|
|
t.Fatalf("grant approval: %v", err)
|
|
}
|
|
|
|
// An empty manifest resource is accepted by package extraction but is
|
|
// rejected while deriving the exact approval capability set. This fails
|
|
// after the old runtime has been stopped and the new record persisted.
|
|
_, err = svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "*"))
|
|
if err == nil {
|
|
t.Fatal("upgrade with invalid capability expected an approval review error")
|
|
}
|
|
if !rt.Running("kandev-plugin-slack") {
|
|
t.Fatal("previous runtime was not restarted after approval review failure")
|
|
}
|
|
if got := rt.startCallCount("kandev-plugin-slack"); got != 2 {
|
|
t.Fatalf("runtime Start called %d times, want initial start plus rollback restart", got)
|
|
}
|
|
onDisk, err := svc.Get("kandev-plugin-slack")
|
|
if err != nil {
|
|
t.Fatalf("get restored plugin: %v", err)
|
|
}
|
|
if onDisk.Version != "1.0.0" {
|
|
t.Fatalf("restored plugin version = %q, want 1.0.0", onDisk.Version)
|
|
}
|
|
}
|
|
|
|
func TestServiceInstallUpgradeReviewRollbackFailureKeepsNewRecordCoherent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fsStore := store.NewFSStore(dir)
|
|
failing := &failMatchingSaveStore{
|
|
Store: fsStore,
|
|
match: func(rec *store.Record) bool { return rec.Version == "1.0.0" },
|
|
failErr: errors.New("simulated compensating save failure"),
|
|
}
|
|
svc := NewService(failing, NewRegistry(), nil, testLogger(t))
|
|
if err := svc.SetPluginsDir(dir); err != nil {
|
|
t.Fatalf("SetPluginsDir: %v", err)
|
|
}
|
|
svc.SetRuntime(newFakeRuntime())
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
if _, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks")); err != nil {
|
|
t.Fatalf("install initial plugin: %v", err)
|
|
}
|
|
failing.arm()
|
|
_, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "*"))
|
|
if err == nil || !strings.Contains(err.Error(), "compensating save failure") {
|
|
t.Fatalf("upgrade error = %v, want compensating save failure", err)
|
|
}
|
|
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
|
|
if getErr != nil {
|
|
t.Fatalf("store.Get(): %v", getErr)
|
|
}
|
|
if onDisk.Version != "1.1.0" {
|
|
t.Fatalf("persisted version = %q, want retained new version 1.1.0", onDisk.Version)
|
|
}
|
|
if _, statErr := os.Stat(onDisk.InstallPath); statErr != nil {
|
|
t.Fatalf("retained new install path missing: %v", statErr)
|
|
}
|
|
current, getErr := svc.Get("kandev-plugin-slack")
|
|
if getErr != nil || current.Version != "1.1.0" {
|
|
t.Fatalf("registry record = %#v, err=%v; want coherent new record", current, getErr)
|
|
}
|
|
}
|
|
|
|
func TestServiceInstallEquivalentUpgradeBumpsApprovalRevision(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks"))
|
|
if err != nil {
|
|
t.Fatalf("install initial plugin: %v", err)
|
|
}
|
|
digest := ManifestCapabilityDigest(rec1.Manifest)
|
|
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, digest, []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if _, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "tasks")); err != nil {
|
|
t.Fatalf("equivalent upgrade: %v", err)
|
|
}
|
|
row, ok, err := svc.approvalCurrent(rec1.InstallationID, "ws-1")
|
|
if err != nil || !ok {
|
|
t.Fatalf("approvalCurrent: ok=%v err=%v", ok, err)
|
|
}
|
|
if row.Revision != 2 {
|
|
t.Fatalf("revision = %d, want 2", row.Revision)
|
|
}
|
|
file, err := svc.approvalLedger().load()
|
|
if err != nil {
|
|
t.Fatalf("load approvals: %v", err)
|
|
}
|
|
if len(file.Events) != 2 || file.Events[1].Type != CapabilityApprovalEventUpgradeReview {
|
|
t.Fatalf("events = %#v, want grant and upgrade review", file.Events)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
type failMatchingSaveStore struct {
|
|
store.Store
|
|
mu sync.Mutex
|
|
armed bool
|
|
match func(*store.Record) bool
|
|
failErr error
|
|
}
|
|
|
|
func (s *failMatchingSaveStore) Save(rec *store.Record) error {
|
|
s.mu.Lock()
|
|
fail := s.armed && s.match(rec)
|
|
if fail {
|
|
s.armed = false
|
|
}
|
|
s.mu.Unlock()
|
|
if fail {
|
|
return s.failErr
|
|
}
|
|
return s.Store.Save(rec)
|
|
}
|
|
|
|
func (s *failMatchingSaveStore) arm() {
|
|
s.mu.Lock()
|
|
s.armed = true
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
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)
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func testPackageWithAPIRead(t *testing.T, id, version string, resources ...string) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
quotedResources := make([]string, 0, len(resources))
|
|
for _, resource := range resources {
|
|
quotedResources = append(quotedResources, fmt.Sprintf("%q", resource))
|
|
}
|
|
minimumVersion := ""
|
|
for _, resource := range resources {
|
|
if resource == "messages" {
|
|
minimumVersion = fmt.Sprintf("min_kandev_version: %q\n", manifest.MinimumMessagesCapabilityVersion)
|
|
break
|
|
}
|
|
}
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: %s
|
|
display_name: Test Plugin
|
|
%s
|
|
capabilities:
|
|
api_read: [%s]
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, version, minimumVersion, strings.Join(quotedResources, ", "), platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// messagesMinKandevVersionPackage is like minKandevVersionPackage but
|
|
// declares capabilities.api_read: messages, which subjects the manifest to
|
|
// the capability minimum floor (MinimumMessagesCapabilityVersion).
|
|
func messagesMinKandevVersionPackage(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
|
|
capabilities:
|
|
api_read:
|
|
- messages
|
|
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_EnforcesMessagesCapabilityFloorOnDevAndUnwiredBuilds
|
|
// pins the design contract that the api_read:messages minimum (0.91.1) is a
|
|
// manifest-level floor enforced in every build - including unstamped dev
|
|
// builds where the newer-than-running release gate is intentionally a no-op.
|
|
// The dev escape hatch applies only to the release-boundary comparison, never
|
|
// to the capability floor.
|
|
func TestServiceInstall_EnforcesMessagesCapabilityFloorOnDevAndUnwiredBuilds(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
want string
|
|
}{
|
|
{name: "below floor is rejected", want: "0.9.0"},
|
|
{name: "malformed minimum is rejected", want: "not-a-release"},
|
|
} {
|
|
t.Run("dev/"+tc.name, func(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetKandevVersion(DevKandevVersion)
|
|
if _, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", tc.want)); err == nil {
|
|
t.Fatalf("Install() on a dev build accepted a messages manifest with min %q", tc.want)
|
|
}
|
|
})
|
|
t.Run("unwired/"+tc.name, func(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
if _, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", tc.want)); err == nil {
|
|
t.Fatalf("Install() with no running version accepted a messages manifest with min %q", tc.want)
|
|
}
|
|
})
|
|
}
|
|
t.Run("dev/floor allowed", func(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetKandevVersion(DevKandevVersion)
|
|
rec, err := svc.Install(context.Background(), messagesMinKandevVersionPackage(t, "kandev-plugin-messages", "1.0.0", "0.91.1"))
|
|
if err != nil {
|
|
t.Fatalf("Install() on a dev build rejected the floor minimum: %v", err)
|
|
}
|
|
if rec.Status != StatusActive {
|
|
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
|
|
}
|
|
})
|
|
}
|