feat: add controlled plugin marketplace lifecycle
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s

This commit is contained in:
Qiufeng
2026-08-28 00:51:34 +08:00
parent dc1c799b98
commit 028b505c36
16 changed files with 1149 additions and 45 deletions
+166
View File
@@ -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())