feat: complete unified plugin admin v1.1.0
This commit is contained in:
@@ -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}}}
|
||||
|
||||
Reference in New Issue
Block a user