feat: add controlled plugin marketplace lifecycle
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -254,6 +256,170 @@ func TestPackageInspectionAndAtomicInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceInstallStagesPackageWithoutStartingAndDeleteSupportsHTTPDelete(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
root := t.TempDir()
|
||||
marketplaceDir := filepath.Join(root, "marketplace")
|
||||
if err := os.MkdirAll(marketplaceDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := validPackage(t, "market.example")
|
||||
archivePath := filepath.Join(marketplaceDir, "market.example-1.0.0.s2plugin")
|
||||
if err := os.WriteFile(archivePath, archive, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := marketplaceIndex{SchemaVersion: 1, Source: "test", Entries: []marketplaceEntry{{
|
||||
PluginID: "market.example", Name: "Example Plugin", Version: "1.0.0",
|
||||
Description: "test package", ArchiveURL: filepath.Base(archivePath), ArchiveSHA256: sha256Hex(archive), ArchiveSize: int64(len(archive)),
|
||||
PublisherKeyID: "dev", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Capabilities: []string{"example.v1"},
|
||||
}}}
|
||||
indexRaw, err := json.Marshal(index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexPath := filepath.Join(marketplaceDir, "index.json")
|
||||
if err := os.WriteFile(indexPath, indexRaw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
case "/api/v1/admin/settings":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
}
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, err := openRegistry(filepath.Join(root, "registry"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(core, reg, root)
|
||||
a.allowUnsigned = true
|
||||
a.marketplaceConfig, err = newMarketplaceService(indexPath, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := adminSession(a)
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/marketplace", nil)
|
||||
listReq.AddCookie(cookie)
|
||||
listRec := httptest.NewRecorder()
|
||||
a.routes().ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), "market.example") || strings.Contains(listRec.Body.String(), filepath.Base(archivePath)) {
|
||||
t.Fatalf("marketplace listing was not metadata-only: %d %s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/marketplace/install", strings.NewReader(`{"plugin_id":"market.example","version":"1.0.0"}`))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", "CSRF")
|
||||
req.Header.Set("Idempotency-Key", "market-install-1")
|
||||
rec := httptest.NewRecorder()
|
||||
a.marketplaceInstall(rec, req)
|
||||
if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), `"completed"`) {
|
||||
t.Fatalf("marketplace install failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
reg.mu.Lock()
|
||||
p, ok := reg.data.Plugins["market.example"]
|
||||
reg.mu.Unlock()
|
||||
if !ok || p.State != "disabled" || p.ActiveRevision == "" {
|
||||
t.Fatalf("marketplace package was not staged as disabled: exists=%v record=%#v", ok, p)
|
||||
}
|
||||
a.mu.Lock()
|
||||
processCount := len(a.processes)
|
||||
a.mu.Unlock()
|
||||
if processCount != 0 {
|
||||
t.Fatalf("marketplace install unexpectedly started %d processes", processCount)
|
||||
}
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/plugins/market.example", nil)
|
||||
deleteReq.AddCookie(cookie)
|
||||
deleteReq.Header.Set("X-CSRF-Token", "CSRF")
|
||||
deleteReq.Header.Set("Idempotency-Key", "market-delete-1")
|
||||
deleteRec := httptest.NewRecorder()
|
||||
a.routes().ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusAccepted {
|
||||
t.Fatalf("DELETE plugin failed: %d %s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
reg.mu.Lock()
|
||||
_, exists := reg.data.Plugins["market.example"]
|
||||
reg.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("plugin remained in registry after DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceRejectsExpiredIndexAndArchiveHashMismatch(t *testing.T) {
|
||||
expired := marketplaceIndex{SchemaVersion: 1, ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)}
|
||||
if err := validateMarketplaceIndex(expired); err == nil {
|
||||
t.Fatal("expected expired marketplace index rejection")
|
||||
}
|
||||
entry := marketplaceEntry{PluginID: "example.plugin", Version: "1.0.0", ArchiveSHA256: strings.Repeat("0", 64), ArchiveSize: 3}
|
||||
if _, err := verifyMarketplaceArchive(entry, []byte("bad")); err == nil {
|
||||
t.Fatal("expected marketplace archive hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverRestoresInterruptedDeleteTombstone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
reg, err := openRegistry(filepath.Join(root, "registry"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(root, "installed", "recover.plugin", "rev-1")
|
||||
if err := os.MkdirAll(filepath.Dir(original), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tombstone := original + ".uninstall-test"
|
||||
if err := os.MkdirAll(tombstone, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tombstone, "marker"), []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg.data.Plugins["recover.plugin"] = pluginRecord{
|
||||
Manifest: manifest.Manifest{PluginID: "recover.plugin", Name: "Recover", Version: "1.0.0"},
|
||||
State: "disabled",
|
||||
ActiveRevision: "rev-1",
|
||||
Revisions: []revision{{ID: "rev-1", Path: original}},
|
||||
}
|
||||
a := newApp(nil, reg, root)
|
||||
if err := a.recoverPlugins(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(original, "marker")); err != nil {
|
||||
t.Fatalf("interrupted uninstall was not restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(tombstone); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("tombstone remained after restoration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"schema_version":1,"source":"test","expires_at":"2099-01-01T00:00:00Z","entries":[]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
if _, err := newMarketplaceService(server.URL, "", true); err == nil {
|
||||
t.Fatal("expected remote marketplace allowlist requirement")
|
||||
}
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := newMarketplaceService(server.URL, u.Host, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, _, err := service.loadIndex(context.Background())
|
||||
if err != nil || index.SchemaVersion != 1 {
|
||||
t.Fatalf("remote marketplace index failed: %#v %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
|
||||
Reference in New Issue
Block a user