feat: complete unified plugin admin v1.1.0
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s

This commit is contained in:
Qiufeng
2026-08-30 12:10:04 +08:00
parent 3c1a17f4d7
commit ada4ab3c21
69 changed files with 6681 additions and 901 deletions
+437 -4
View File
@@ -195,6 +195,123 @@ func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) {
}
}
func TestCaptchaConfigReturnsOnlyPublicFields(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/settings/public" {
t.Fatalf("unexpected Core path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"turnstile_enabled":true,"turnstile_site_key":"site-key","tencent_captcha_enabled":false,"aliyun_captcha_enabled":false,"turnstile_secret_key":"must-not-leak","admin_password":"must-not-leak"}}`)
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
recorder := httptest.NewRecorder()
a.routes().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/captcha-config", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
if !strings.Contains(body, `"turnstile_enabled":true`) || !strings.Contains(body, `"turnstile_site_key":"site-key"`) {
t.Fatalf("public captcha fields missing: %s", body)
}
if strings.Contains(body, "must-not-leak") || strings.Contains(body, "secret_key") || strings.Contains(body, "admin_password") {
t.Fatalf("sensitive Core settings leaked: %s", body)
}
}
func TestLoginForwardsCaptchaProof(t *testing.T) {
var received struct {
TurnstileToken string `json:"turnstile_token"`
}
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/login":
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode login body: %v", err)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password","turnstile_token":"proof-token"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if rec.Code != http.StatusOK || received.TurnstileToken != "proof-token" {
t.Fatalf("proof was not forwarded: status=%d body=%s received=%#v", rec.Code, rec.Body.String(), received)
}
}
func TestLoginRateLimitBoundsCoreAttempts(t *testing.T) {
var loginCalls int
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/login":
loginCalls++
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
for attempt := 0; attempt < loginLimit+1; attempt++ {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
rec := httptest.NewRecorder()
a.login(rec, req)
if attempt < loginLimit && rec.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
}
if attempt == loginLimit && rec.Code != http.StatusTooManyRequests {
t.Fatalf("limit status=%d body=%s", rec.Code, rec.Body.String())
}
}
if loginCalls != loginLimit {
t.Fatalf("Core received %d login calls, want %d", loginCalls, loginLimit)
}
}
func TestPendingLoginCapAndExpiry(t *testing.T) {
now := time.Now()
a := newApp(nil, nil, t.TempDir())
a.clock = func() time.Time { return now }
for i := 0; i < maxPendingLogins; i++ {
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "TEMP", Expires: now.Add(time.Minute)}); !ok {
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
}
}
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "OVERFLOW", Expires: now.Add(time.Minute)}); ok {
t.Fatal("pending challenge cap was not enforced")
}
if got := len(a.pending); got != maxPendingLogins {
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
}
a.clock = func() time.Time { return now.Add(pendingLoginTTL + time.Second) }
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "AFTER-EXPIRY", Expires: now.Add(2 * pendingLoginTTL)}); !ok {
t.Fatal("expired pending challenges were not evicted")
}
if got := len(a.pending); got != 1 {
t.Fatalf("pending map size after expiry=%d want 1", got)
}
}
func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
var input struct {
Name string `json:"name"`
@@ -420,6 +537,43 @@ func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) {
}
}
func TestMarketplaceVersionComparisonRejectsDowngrades(t *testing.T) {
for _, test := range []struct {
left, right string
want int
}{
{"1.2.0", "1.1.9", 1},
{"1.0.0", "1.0.0", 0},
{"1.0.0-beta.2", "1.0.0-beta.10", -1},
{"1.0.0", "1.0.0-rc.1", 1},
} {
if got := compareMarketplaceVersions(test.left, test.right); got != test.want {
t.Fatalf("compare(%q,%q)=%d want %d", test.left, test.right, got, test.want)
}
}
}
func TestMarketplaceIndexDigestPin(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "index.json")
raw := []byte(`{"schema_version":1,"entries":[]}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
service, err := newMarketplaceService(path, "", true)
if err != nil {
t.Fatal(err)
}
service.indexSHA256 = sha256Hex(raw)
if _, _, err := service.loadIndex(context.Background()); err != nil {
t.Fatalf("matching digest rejected: %v", err)
}
service.indexSHA256 = strings.Repeat("0", 64)
if _, _, err := service.loadIndex(context.Background()); err == nil {
t.Fatal("mismatched digest was accepted")
}
}
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
@@ -587,6 +741,7 @@ func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
func TestRefreshRevalidatesAdminRole(t *testing.T) {
var meCalls int
var logoutTokens []string
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
@@ -601,6 +756,9 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
case "/api/v1/auth/refresh":
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
case "/api/v1/auth/logout":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
logoutTokens = append(logoutTokens, body["refresh_token"])
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
@@ -619,6 +777,16 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
if rec.Code != http.StatusForbidden {
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String())
}
foundRotated := false
for _, value := range logoutTokens {
if value == "NEW-R" {
foundRotated = true
break
}
}
if !foundRotated {
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
}
}
func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
@@ -647,9 +815,209 @@ func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
csp := response.Header.Get("Content-Security-Policy")
if response.StatusCode != http.StatusUnauthorized || csp == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header)
}
for _, origin := range []string{"https://challenges.cloudflare.com", "https://turing.captcha.qcloud.com", "https://o.alicdn.com"} {
if !strings.Contains(csp, origin) {
t.Fatalf("captcha origin %q missing from CSP: %s", origin, csp)
}
}
}
func staticTestApp(t *testing.T) *app {
t.Helper()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
return newApp(nil, reg, t.TempDir())
}
func TestStaticServesAssetsWithMIME(t *testing.T) {
a := staticTestApp(t)
for path, wantType := range map[string]string{"/admin/app.js": "text/javascript", "/admin/styles.css": "text/css"} {
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), wantType) {
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
}
}
}
func TestStaticRejectsPathTraversal(t *testing.T) {
a := staticTestApp(t)
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, "/admin/%2e%2e/main.go", nil))
if recorder.Code != http.StatusNotFound || strings.Contains(recorder.Body.String(), "package main") {
t.Fatalf("expected traversal to be rejected: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestStaticReplacesHTMLBasePlaceholderAndSupportsMountedSPA(t *testing.T) {
a := staticTestApp(t)
a.publicBasePath = "/console"
for _, path := range []string{"/console/admin/", "/console/admin/plugins/example.plugin/config"} {
recorder := httptest.NewRecorder()
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/html") {
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
}
body := recorder.Body.String()
if strings.Contains(body, "__PLUGIN_BASE_PATH_JSON__") || !strings.Contains(body, `meta name="plugin-base-path" content="/console"`) || strings.Contains(body, "window.__PLUGIN_BASE_PATH__ =") {
t.Fatalf("path=%s base placeholder was not replaced: %q", path, body)
}
}
}
func TestSubscriptionProxySharesControlPlaneSessionAndRedactsCoreSecrets(t *testing.T) {
var gotAuthorization, gotPath string
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","email":"admin@example.com"}}`)
case "/api/v1/admin/subscriptions":
gotAuthorization = r.Header.Get("Authorization")
gotPath = r.URL.RequestURI()
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_LEAK","items":[{"id":1,"refresh_token":"REFRESH_LEAK","status":"active"}]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0"}, State: "healthy", ActiveRevision: "rev-1"}
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&evil=ignored", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
a.subscriptionProxy(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected unknown query to be rejected, status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&page_size=20", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
a.subscriptionProxy(rec, req)
if rec.Code != http.StatusOK || gotAuthorization != "Bearer ACCESS" || gotPath != "/api/v1/admin/subscriptions?page=1&page_size=20" {
t.Fatalf("status=%d auth=%q path=%q body=%s", rec.Code, gotAuthorization, gotPath, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "CORE_LEAK") || strings.Contains(rec.Body.String(), "REFRESH_LEAK") {
t.Fatalf("Core secret leaked in module response: %s", rec.Body.String())
}
}
func TestSubscriptionStatusDeclaresSharedSession(t *testing.T) {
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, err := openRegistry(t.TempDir())
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, t.TempDir())
req := httptest.NewRequest(http.MethodGet, "/api/subscription/status", nil)
req.AddCookie(adminSession(a))
rec := httptest.NewRecorder()
a.subscriptionStatus(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"session_mode":"shared"`) || strings.Contains(rec.Body.String(), "access_token") {
t.Fatalf("unexpected status response: %d %s", rec.Code, rec.Body.String())
}
}
func TestSubscriptionProxyRequiresHealthyModule(t *testing.T) {
var coreReads int
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/auth/me" {
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
return
}
if r.URL.Path == "/api/v1/admin/subscriptions" {
coreReads++
}
_, _ = io.WriteString(w, `{"code":0,"data":{"items":[]}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
cookie := adminSession(a)
request := func(endpoint string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, endpoint, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
if endpoint == "/api/subscription/audit" {
a.subscriptionAudit(rec, req)
} else {
a.subscriptionProxy(rec, req)
}
return rec
}
endpoints := []string{
"/api/subscription/plans",
"/api/subscription/subscriptions",
"/api/subscription/subscriptions/1",
"/api/subscription/users/1",
"/api/subscription/users/1/subscriptions",
"/api/subscription/audit",
}
for _, endpoint := range endpoints {
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
t.Fatalf("uninstalled module endpoint=%s leaked data: status=%d reads=%d body=%s", endpoint, rec.Code, coreReads, rec.Body.String())
}
}
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "disabled"}
for _, endpoint := range endpoints {
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
t.Fatalf("stopped module endpoint=%s leaked data: status=%d reads=%d", endpoint, rec.Code, coreReads)
}
}
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "healthy"}
if rec := request("/api/subscription/subscriptions"); rec.Code != http.StatusOK || coreReads != 1 {
t.Fatalf("healthy module was not proxied: status=%d reads=%d body=%s", rec.Code, coreReads, rec.Body.String())
}
}
func TestSubscriptionMenuAlwaysTargetsUnifiedShell(t *testing.T) {
var applied []byte
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":
if r.Method == http.MethodPut {
applied, _ = io.ReadAll(r.Body)
}
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
a.publicURL = "https://console.example.test/extensions/qiu.plugin-admin"
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0", UI: manifest.UI{Menu: manifest.Menu{ID: subscriptionPluginID, Label: "订阅管理", Visibility: "admin", SortOrder: 200, URL: "https://wrong.example.test/login"}}}, State: "healthy", ActiveRevision: "rev-1"}
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+subscriptionPluginID+"/menu-apply", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "subscription-menu-unified")
rec := httptest.NewRecorder()
a.menu(rec, req, true)
want := "https://console.example.test/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview"
if rec.Code != http.StatusAccepted || !strings.Contains(string(applied), want) || strings.Contains(string(applied), "wrong.example.test") {
t.Fatalf("subscription menu was not normalized: status=%d body=%s applied=%s", rec.Code, rec.Body.String(), applied)
}
}
func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
@@ -682,7 +1050,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
preview.Header.Set("Idempotency-Key", "menu-preview-1")
previewRec := httptest.NewRecorder()
a.menu(previewRec, preview, false)
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") {
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example-plugin") {
t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String())
}
global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`))
@@ -691,7 +1059,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
global.Header.Set("Idempotency-Key", "global-menu-preview-1")
globalRec := httptest.NewRecorder()
a.menuGlobal(globalRec, global, false)
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") {
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example-plugin") {
t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String())
}
apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil)
@@ -700,11 +1068,28 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
apply.Header.Set("Idempotency-Key", "menu-apply-1")
applyRec := httptest.NewRecorder()
a.menu(applyRec, apply, true)
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") {
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example-plugin") || strings.Contains(string(applied), "example.plugin") {
t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied)
}
}
func TestCoreMenuIDNormalizesPluginNamespaceAndBoundsLength(t *testing.T) {
if got, err := coreMenuID("qiu.subscription-admin"); err != nil || got != "qiu-subscription-admin" {
t.Fatalf("namespace ID normalization: got=%q err=%v", got, err)
}
if got, err := coreMenuID("already_valid"); err != nil || got != "already_valid" {
t.Fatalf("valid ID changed: got=%q err=%v", got, err)
}
long := strings.Repeat("plugin.", 12)
got, err := coreMenuID(long)
if err != nil || len(got) > 32 || !coreMenuIDPattern.MatchString(got) {
t.Fatalf("long ID normalization: got=%q len=%d err=%v", got, len(got), err)
}
if _, err := coreMenuID("..."); err == nil {
t.Fatal("expected an all-separator ID to be rejected")
}
}
func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -790,6 +1175,51 @@ func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
}
}
func TestRollbackHonorsExplicitRevisionSelection(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","version":"0.8.0"}`)
return
}
http.NotFound(w, r)
}))
defer pluginServer.Close()
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
}))
defer coreServer.Close()
reg, _ := openRegistry(t.TempDir())
a := newApp(core, reg, t.TempDir())
base := manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}
oldManifest, middleManifest := base, base
oldManifest.Version, middleManifest.Version = "0.8.0", "0.9.0"
p := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "rev-new", Endpoint: pluginServer.URL, Revisions: []revision{
{ID: "rev-old", Version: "0.8.0", Manifest: oldManifest},
{ID: "rev-middle", Version: "0.9.0", Manifest: middleManifest},
{ID: "rev-new", Version: "1.0.0", Manifest: base},
}}
reg.data.Plugins[p.Manifest.PluginID] = p
cookie := adminSession(a)
req := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", strings.NewReader(`{"revision":"rev-old"}`))
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "rollback-explicit-old")
rec := httptest.NewRecorder()
a.rollback(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("rollback status=%d body=%s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
got := reg.data.Plugins[p.Manifest.PluginID]
reg.mu.Unlock()
if got.ActiveRevision != "rev-old" || got.Manifest.Version != "0.8.0" {
t.Fatalf("explicit rollback selected wrong revision: active=%q manifest=%q", got.ActiveRevision, got.Manifest.Version)
}
}
func TestLifecycleEnableDisableUninstall(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
var applied []byte
@@ -1024,6 +1454,9 @@ func TestRecoverCommandPluginAfterRestart(t *testing.T) {
t.Skip("python3 is unavailable")
}
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1)
if err := os.WriteFile(command, []byte(source), 0o700); err != nil {
t.Fatal(err)
}
reg, _ := openRegistry(filepath.Join(root, "registry"))
pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}}
reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}}