package main import ( "archive/zip" "bytes" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" "git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest" ) func testCore(t *testing.T, handler http.Handler) (*coreClient, *httptest.Server) { t.Helper() server := httptest.NewServer(handler) client, err := newCoreClient(server.URL) if err != nil { server.Close() t.Fatal(err) } return client, server } func adminSession(a *app) *http.Cookie { now := time.Now() id := "session" a.sessions[id] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin", "email": "admin@example.com"}, CreatedAt: now, LastSeen: now} a.sessionLocks[id] = &sync.Mutex{} return &http.Cookie{Name: sessionCookieName, Value: id} } func validPackage(t *testing.T, id string) []byte { return validPackageVersion(t, id, "1.0.0") } func validPackageVersion(t *testing.T, id, version string) []byte { t.Helper() ui := []byte("plugin") manifestValue := map[string]any{ "schema_version": 1, "plugin_id": id, "name": "Example Plugin", "version": version, "core_api_baseline": "sub2api-0.1.183", "tested_core_versions": []string{"0.1.183"}, "capabilities": []string{"example.v1"}, "backend": map[string]any{"health_path": "/healthz", "readiness_path": "/readyz", "listen_env": "PLUGIN_PORT"}, "ui": map[string]any{"entrypoint": "ui/index.html", "menu": map[string]any{"id": id, "label": "Example", "visibility": "admin", "sort_order": 200}}, "publisher": map[string]any{"key_id": "dev"}, "core_api_allowlist": []string{"POST /api/v1/auth/login", "POST /api/v1/auth/login/2fa", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/auth/me", "GET /api/v1/settings/public"}, } manifestValue["files"] = map[string]string{"ui/index.html": sha256Hex(ui)} manifestBytes, err := json.Marshal(manifestValue) if err != nil { t.Fatal(err) } var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, data := range map[string][]byte{"manifest.json": manifestBytes, "ui/index.html": ui} { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write(data); err != nil { t.Fatal(err) } } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func signedPackage(t *testing.T, id, version string) ([]byte, ed25519.PublicKey) { t.Helper() raw := validPackageVersion(t, id, version) zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatal(err) } entries := make(map[string][]byte, len(zr.File)) var manifestBytes []byte for _, file := range zr.File { reader, openErr := file.Open() if openErr != nil { t.Fatal(openErr) } data, readErr := io.ReadAll(reader) _ = reader.Close() if readErr != nil { t.Fatal(readErr) } entries[file.Name] = data if file.Name == "manifest.json" { manifestBytes = data } } publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } signature := manifest.Signature{Algorithm: "ed25519", KeyID: "dev", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))} signatureBytes, err := json.Marshal(signature) if err != nil { t.Fatal(err) } entries["signature.json"] = signatureBytes var out bytes.Buffer zw := zip.NewWriter(&out) for name, data := range entries { writer, createErr := zw.Create(name) if createErr != nil { t.Fatal(createErr) } if _, writeErr := writer.Write(data); writeErr != nil { t.Fatal(writeErr) } } if err := zw.Close(); err != nil { t.Fatal(err) } return out.Bytes(), publicKey } func uploadRequest(t *testing.T, path string, cookie *http.Cookie, csrf, idempotency string, archive []byte) *http.Request { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("package", "plugin.s2plugin") if err != nil { t.Fatal(err) } if _, err := part.Write(archive); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodPost, path, &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-CSRF-Token", csrf) req.Header.Set("Idempotency-Key", idempotency) req.AddCookie(cookie) return req } func sha256Hex(value []byte) string { sum := sha256.Sum256(value) return hex.EncodeToString(sum[:]) } func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) { 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": _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_ACCESS","refresh_token":"CORE_REFRESH"}}`) case "/api/v1/auth/me": _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com","access_token":"LEAK"}}`) 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()) request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`)) recorder := httptest.NewRecorder() a.login(recorder, request) if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "CORE_ACCESS") || strings.Contains(recorder.Body.String(), "CORE_REFRESH") || strings.Contains(recorder.Body.String(), "LEAK") { t.Fatalf("unexpected login response: %d %s", recorder.Code, recorder.Body.String()) } if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly { t.Fatalf("expected HttpOnly plugin cookie: %#v", recorder.Result().Cookies()) } } func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) { var input struct { Name string `json:"name"` } trailing := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}{}`)) if err := decodeJSON(trailing, &input, 1<<20); err == nil { t.Fatal("expected concatenated JSON to be rejected") } oversized := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"plugin"}`)) if err := decodeJSON(oversized, &input, 4); err == nil { t.Fatal("expected oversized JSON body to be rejected") } } func TestOrdinaryCoreUserIsRejected(t *testing.T) { 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/login" { _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`) return } _, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`) })) defer coreServer.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) recorder := httptest.NewRecorder() a.login(recorder, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"user@example.com","password":"password"}`))) if recorder.Code != http.StatusForbidden { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestPackageInspectionAndAtomicInstall(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") reg, err := openRegistry(t.TempDir()) if err != nil { t.Fatal(err) } a := newApp(nil, reg, filepath.Dir(reg.path)) a.allowUnsigned = true raw := validPackage(t, "example.plugin") info, err := a.inspectPackage(raw) if err != nil { t.Fatal(err) } p, err := a.installPackage(info) if err != nil { t.Fatal(err) } if p.State != "disabled" || p.ActiveRevision == "" { t.Fatalf("unexpected installed record: %#v", p) } if _, err := os.Stat(filepath.Join(p.Revisions[0].Path, "ui", "index.html")); err != nil { t.Fatal(err) } if _, err := a.inspectPackage(bytes.Replace(raw, []byte("ui/index.html"), []byte("../secret"), 1)); err == nil { t.Fatal("expected invalid package") } } func TestProductionPackageRequiresTrustedSignature(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) a.allowUnsigned = false if _, err := a.inspectPackage(validPackage(t, "unsigned.plugin")); err == nil { t.Fatal("expected unsigned package rejection") } raw, publicKey := signedPackage(t, "signed.plugin", "1.0.0") a.trustedPublishers = map[string][]byte{"dev": publicKey} if _, err := a.inspectPackage(raw); err != nil { t.Fatalf("trusted signed package rejected: %v", err) } a.trustedPublishers = map[string][]byte{} if _, err := a.inspectPackage(raw); err == nil { t.Fatal("expected untrusted publisher rejection") } } func TestDuplicateInstallCannotReplaceActiveRevision(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) a.allowUnsigned = true first, err := a.installPackage(a.packageForTest(t, "duplicate.plugin", "1.0.0")) if err != nil { t.Fatal(err) } secondInfo := a.packageForTest(t, "duplicate.plugin", "2.0.0") if _, err := a.installPackage(secondInfo); err == nil || !strings.Contains(err.Error(), "already installed") { t.Fatalf("expected duplicate install rejection, got %v", err) } reg.mu.Lock() current := reg.data.Plugins["duplicate.plugin"] reg.mu.Unlock() if current.ActiveRevision != first.ActiveRevision || current.Manifest.Version != "1.0.0" { t.Fatalf("duplicate install replaced active revision: %#v", current) } } func TestConfigIsEncryptedAndSecretsAreNotReturned(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, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0"}, State: "disabled", Revisions: []revision{}} reg.data.Plugins[p.Manifest.PluginID] = p now := time.Now() a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now} a.sessionLocks["sid"] = &sync.Mutex{} req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090","client_secret":"TOP-SECRET"}`)) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) req.Header.Set("X-CSRF-Token", "CSRF") req.Header.Set("Idempotency-Key", "config-1") rec := httptest.NewRecorder() a.config(rec, req) if rec.Code != http.StatusAccepted || strings.Contains(rec.Body.String(), "TOP-SECRET") { t.Fatalf("config response leaked secret: %d %s", rec.Code, rec.Body.String()) } reg.mu.Lock() stored := reg.data.Plugins[p.Manifest.PluginID].ConfigCipher reg.mu.Unlock() if stored == "" || strings.Contains(stored, "TOP-SECRET") { t.Fatalf("config was not encrypted: %q", stored) } get := httptest.NewRequest(http.MethodGet, "/api/plugins/example.plugin/config", nil) get.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) getRec := httptest.NewRecorder() a.config(getRec, get) if getRec.Code != http.StatusOK || strings.Contains(getRec.Body.String(), "TOP-SECRET") || !strings.Contains(getRec.Body.String(), "configured") { t.Fatalf("config metadata response: %d %s", getRec.Code, getRec.Body.String()) } } func TestMutationRequiresCSRFAndIdempotency(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, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} a.sessionLocks["sid"] = &sync.Mutex{} req := httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) req.Header.Set("Idempotency-Key", "enable-1") rec := httptest.NewRecorder() a.enable(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("missing csrf status=%d body=%s", rec.Code, rec.Body.String()) } req = httptest.NewRequest(http.MethodPost, "/api/plugins/nope/enable", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) req.Header.Set("X-CSRF-Token", "CSRF") rec = httptest.NewRecorder() a.enable(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("missing idempotency status=%d body=%s", rec.Code, rec.Body.String()) } } func TestIdempotencyKeyRejectsDifferentOperationHash(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, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} a.sessionLocks["sid"] = &sync.Mutex{} first := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"a"}`)) first.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) first.Header.Set("X-CSRF-Token", "CSRF") first.Header.Set("Idempotency-Key", "same-key") op, _, ok := a.mutationAuth(httptest.NewRecorder(), first, "enable", "example.plugin") if !ok || op.ID == "" { t.Fatal("first operation was not allocated") } second := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", strings.NewReader(`{"payload":"b"}`)) second.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) second.Header.Set("X-CSRF-Token", "CSRF") second.Header.Set("Idempotency-Key", "same-key") rec := httptest.NewRecorder() _, _, ok = a.mutationAuth(rec, second, "enable", "example.plugin") if ok || rec.Code != http.StatusConflict { t.Fatalf("expected idempotency conflict: status=%d body=%s", rec.Code, rec.Body.String()) } } func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(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, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) a.sessions["sid"] = session{AccessToken: "A", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} a.sessionLocks["sid"] = &sync.Mutex{} newRequest := func() *http.Request { req := httptest.NewRequest(http.MethodPut, "/api/plugins/example.plugin/config", strings.NewReader(`{"service_url":"http://127.0.0.1:18090"}`)) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) req.Header.Set("X-CSRF-Token", "CSRF") req.Header.Set("Idempotency-Key", "config-same") return req } first, _, ok := a.mutationAuth(httptest.NewRecorder(), newRequest(), "config", "example.plugin") if !ok { t.Fatal("first operation was not allocated") } if _, err := a.registry.finishOperation(first, errors.New("expected failure")); err != nil { t.Fatal(err) } secondRecorder := httptest.NewRecorder() _, _, ok = a.mutationAuth(secondRecorder, newRequest(), "config", "example.plugin") if ok || secondRecorder.Code != http.StatusAccepted || !strings.Contains(secondRecorder.Body.String(), first.ID) || !strings.Contains(secondRecorder.Body.String(), `"failed"`) { t.Fatalf("expected failed operation replay: status=%d body=%s", secondRecorder.Code, secondRecorder.Body.String()) } } func TestRefreshRevalidatesAdminRole(t *testing.T) { var meCalls 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/me": meCalls++ if meCalls == 1 { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"code":401,"message":"expired"}`) return } _, _ = io.WriteString(w, `{"code":0,"data":{"id":2,"role":"user"}}`) case "/api/v1/auth/refresh": _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`) case "/api/v1/auth/logout": _, _ = io.WriteString(w, `{"code":0,"data":{}}`) default: _, _ = io.WriteString(w, `{"code":0,"data":{}}`) } })) defer coreServer.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) now := time.Now() a.sessions["sid"] = session{AccessToken: "OLD", RefreshToken: "R", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: now, LastSeen: now} a.sessionLocks["sid"] = &sync.Mutex{} req := httptest.NewRequest(http.MethodGet, "/api/me", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) rec := httptest.NewRecorder() a.me(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String()) } } func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/healthz" { http.Redirect(w, r, "http://127.0.0.1:1/internal", http.StatusFound) return } _, _ = io.WriteString(w, `{"status":"ready","version":"1.0.0"}`) })) defer server.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) p := pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Version: "1.0.0", Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, Endpoint: server.URL} if err := a.checkPluginHealth(&p); err == nil { t.Fatal("expected redirecting health endpoint to fail closed") } } func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) { reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) server := httptest.NewServer(a.routes()) defer server.Close() response, err := server.Client().Get(server.URL + "/api/plugins") 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" { t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header) } } func TestMenuPreviewAndApplyPreserveOtherMenuItems(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":[{"id":"core.home","label":"首页"}]}}`) default: _, _ = io.WriteString(w, `{"code":0,"data":{}}`) } })) defer coreServer.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} a.sessionLocks["sid"] = &sync.Mutex{} menuURL := "http://127.0.0.1:18091" reg.data.Plugins["example.plugin"] = pluginRecord{Manifest: manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: "example.plugin", Label: "示例插件", Visibility: "admin", SortOrder: 200, URL: menuURL}}}, State: "healthy", ActiveRevision: "rev-1"} cookie := &http.Cookie{Name: sessionCookieName, Value: "sid"} preview := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-preview", nil) preview.AddCookie(cookie) preview.Header.Set("X-CSRF-Token", "CSRF") 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") { 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"}`)) global.AddCookie(cookie) global.Header.Set("X-CSRF-Token", "CSRF") 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") { 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) apply.AddCookie(cookie) apply.Header.Set("X-CSRF-Token", "CSRF") 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") { t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied) } } func TestFailedUpgradeKeepsActiveRevision(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.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()) a.allowUnsigned = true old, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0")) if err != nil { t.Fatal(err) } old.Endpoint = pluginServer.URL old.State = "healthy" reg.mu.Lock() reg.data.Plugins["example.plugin"] = old if err := reg.saveLocked(); err != nil { reg.mu.Unlock() t.Fatal(err) } reg.mu.Unlock() a.sessions["sid"] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin"}, CreatedAt: time.Now(), LastSeen: time.Now()} a.sessionLocks["sid"] = &sync.Mutex{} req := uploadRequest(t, "/api/plugins/example.plugin/upgrade", &http.Cookie{Name: sessionCookieName, Value: "sid"}, "CSRF", "upgrade-1", validPackageVersion(t, "example.plugin", "2.0.0")) rec := httptest.NewRecorder() a.install(rec, req, true, "example.plugin") if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "operation_id") { t.Fatalf("unexpected upgrade response: %d %s", rec.Code, rec.Body.String()) } reg.mu.Lock() current := reg.data.Plugins["example.plugin"] reg.mu.Unlock() if current.ActiveRevision != old.ActiveRevision || current.PendingRevision == "" || current.State != "rollback_pending" || current.Manifest.Version != "1.0.0" { t.Fatalf("active revision changed after failed upgrade: %#v", current) } pendingID := current.PendingRevision var pendingPath string for _, rev := range current.Revisions { if rev.ID == pendingID { pendingPath = rev.Path } } if pendingPath == "" { t.Fatal("failed upgrade did not retain pending revision path") } rollback := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", nil) rollback.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"}) rollback.Header.Set("X-CSRF-Token", "CSRF") rollback.Header.Set("Idempotency-Key", "rollback-after-failure") rollbackRec := httptest.NewRecorder() a.rollback(rollbackRec, rollback) if rollbackRec.Code != http.StatusAccepted { t.Fatalf("rollback failed: %d %s", rollbackRec.Code, rollbackRec.Body.String()) } reg.mu.Lock() restored := reg.data.Plugins["example.plugin"] reg.mu.Unlock() if restored.ActiveRevision != old.ActiveRevision || restored.PendingRevision != "" || restored.State != "healthy" || restored.Manifest.Version != "1.0.0" { t.Fatalf("failed-upgrade rollback did not restore old revision: %#v", restored) } var retired bool for _, rev := range restored.Revisions { if rev.ID == pendingID { retired = rev.Retired } } if !retired { t.Fatal("failed candidate was not marked retired") } if _, err := os.Stat(pendingPath); err != nil { t.Fatalf("retired candidate path was removed before registry commit: %v", err) } } func TestLifecycleEnableDisableUninstall(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") var applied []byte pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.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") 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":[{"id":"core.home","label":"首页"},{"id":"example.plugin","label":"示例"}]}}`) default: _, _ = io.WriteString(w, `{"code":0,"data":{}}`) } })) defer coreServer.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) a.allowUnsigned = true p, err := a.installPackage(a.packageForTest(t, "example.plugin", "1.0.0")) if err != nil { t.Fatal(err) } p.Endpoint = pluginServer.URL reg.mu.Lock() reg.data.Plugins[p.Manifest.PluginID] = p reg.mu.Unlock() cookie := adminSession(a) enable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/enable", nil) enable.AddCookie(cookie) enable.Header.Set("X-CSRF-Token", "CSRF") enable.Header.Set("Idempotency-Key", "enable-lifecycle") enableRec := httptest.NewRecorder() a.enable(enableRec, enable) if enableRec.Code != http.StatusAccepted { t.Fatalf("enable failed: %d %s", enableRec.Code, enableRec.Body.String()) } reg.mu.Lock() if reg.data.Plugins["example.plugin"].State != "healthy" { t.Fatalf("plugin did not become healthy: %#v", reg.data.Plugins["example.plugin"]) } reg.mu.Unlock() disable := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/disable", nil) disable.AddCookie(cookie) disable.Header.Set("X-CSRF-Token", "CSRF") disable.Header.Set("Idempotency-Key", "disable-lifecycle") disableRec := httptest.NewRecorder() a.disable(disableRec, disable) if disableRec.Code != http.StatusAccepted || strings.Contains(string(applied), "example.plugin") { t.Fatalf("disable did not remove own menu: %d body=%s request=%s", disableRec.Code, disableRec.Body.String(), applied) } uninstall := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/uninstall", nil) uninstall.AddCookie(cookie) uninstall.Header.Set("X-CSRF-Token", "CSRF") uninstall.Header.Set("Idempotency-Key", "uninstall-lifecycle") uninstallRec := httptest.NewRecorder() a.uninstall(uninstallRec, uninstall) if uninstallRec.Code != http.StatusAccepted { t.Fatalf("uninstall failed: %d %s", uninstallRec.Code, uninstallRec.Body.String()) } reg.mu.Lock() _, exists := reg.data.Plugins["example.plugin"] reg.mu.Unlock() if exists { t.Fatal("plugin remained in registry after uninstall") } } func TestUninstallRegistryFailureRestoresRevisionPath(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") root := t.TempDir() registryDir := t.TempDir() reg, err := openRegistry(registryDir) if err != nil { t.Fatal(err) } pluginID := "restore.plugin" revisionPath := filepath.Join(root, "installed", pluginID, "rev-1") if err := os.MkdirAll(revisionPath, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(revisionPath, "marker"), []byte("keep"), 0o600); err != nil { t.Fatal(err) } reg.data.Plugins[pluginID] = pluginRecord{ Manifest: manifest.Manifest{ PluginID: pluginID, Name: "Restore", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"}, UI: manifest.UI{Entrypoint: "ui/index.html", Menu: manifest.Menu{ID: pluginID, Label: "Restore", Visibility: "admin", SortOrder: 200, URL: "http://127.0.0.1:8090"}}, }, State: "disabled", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: revisionPath}}, UpdatedAt: time.Now().UTC(), } if err := reg.save(); 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": if r.Method == http.MethodPut { // Force the lifecycle registry commit to fail after the menu // update, exercising path restoration as well as record rollback. reg.path = t.TempDir() } _, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[{"id":"`+pluginID+`","label":"Restore"}]}}`) default: _, _ = io.WriteString(w, `{"code":0,"data":{}}`) } })) defer coreServer.Close() a := newApp(core, reg, root) cookie := adminSession(a) req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+pluginID+"/uninstall", nil) req.AddCookie(cookie) req.Header.Set("X-CSRF-Token", "CSRF") req.Header.Set("Idempotency-Key", "uninstall-restore") rec := httptest.NewRecorder() a.uninstall(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected persistence failure, got %d body=%s", rec.Code, rec.Body.String()) } reg.mu.Lock() restored, exists := reg.data.Plugins[pluginID] reg.mu.Unlock() if !exists || len(restored.Revisions) != 1 || restored.Revisions[0].Path != revisionPath { t.Fatalf("registry record was not restored: exists=%v record=%#v", exists, restored) } if _, err := os.Stat(filepath.Join(revisionPath, "marker")); err != nil { t.Fatalf("revision path was not restored: %v", err) } } func TestPluginLockSerializesLifecycleMutations(t *testing.T) { reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) firstEntered := make(chan struct{}) release := make(chan struct{}) secondEntered := make(chan struct{}) go func() { unlock := a.lockPlugin("example.plugin") close(firstEntered) <-release unlock() }() <-firstEntered go func() { unlock := a.lockPlugin("example.plugin") close(secondEntered) unlock() }() select { case <-secondEntered: t.Fatal("second plugin mutation acquired the lock concurrently") case <-time.After(25 * time.Millisecond): } close(release) select { case <-secondEntered: case <-time.After(time.Second): t.Fatal("second plugin mutation did not proceed after release") } } func TestRecoverExternalPluginAfterRestart(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/healthz" && r.URL.Path != "/readyz" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"status":"ok","version":"1.0.0"}`) })) defer server.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) p := pluginRecord{Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}, State: "healthy", ActiveRevision: "rev-1", Endpoint: server.URL, Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Manifest: manifest.Manifest{PluginID: "external.plugin", Name: "External", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}}}} reg.data.Plugins[p.Manifest.PluginID] = p if err := reg.save(); err != nil { t.Fatal(err) } if err := a.recoverPlugins(); err != nil { t.Fatal(err) } reg.mu.Lock() recovered := reg.data.Plugins[p.Manifest.PluginID] reg.mu.Unlock() if recovered.State != "healthy" || recovered.Endpoint != server.URL || recovered.LastError != "" { t.Fatalf("external plugin was not recovered: %#v", recovered) } } func TestRecoverCommandPluginAfterRestart(t *testing.T) { t.Setenv("CORE_VERSION", "0.1.183") if _, err := os.Stat("/bin/sh"); err != nil { t.Skip("shell is unavailable") } root := t.TempDir() pluginDir := filepath.Join(root, "installed", "command.plugin", "rev-1") if err := os.MkdirAll(filepath.Join(pluginDir, "service"), 0o700); err != nil { t.Fatal(err) } // The helper is a tiny Python HTTP server available in the local test // environment; it binds the supervisor-provided loopback port. command := filepath.Join(pluginDir, "service", "run.py") source := "#!/usr/bin/env python3\nimport http.server, os\nclass H(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n if self.path in ('/healthz','/readyz'):\n body=b'{\\\"status\\\":\\\"ok\\\",\\\"version\\\":\\\"1.0.0\\\"}'\n self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(body))); self.end_headers(); self.wfile.write(body)\n else: self.send_response(404); self.end_headers()\n def log_message(self,*args): pass\nhttp.server.HTTPServer(('127.0.0.1', int(os.environ['PLUGIN_PORT'])), H).serve_forever()\n" if err := os.WriteFile(command, []byte(source), 0o700); err != nil { t.Fatal(err) } pythonPath, err := exec.LookPath("python3") if err != nil { t.Skip("python3 is unavailable") } source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1) 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}}} if err := reg.save(); err != nil { t.Fatal(err) } a := newApp(nil, reg, root) if err := a.recoverPlugins(); err != nil { t.Fatal(err) } reg.mu.Lock() recovered := reg.data.Plugins[pluginManifest.PluginID] reg.mu.Unlock() if recovered.State != "healthy" || !strings.HasPrefix(recovered.Endpoint, "http://127.0.0.1:") { t.Fatalf("command plugin was not recovered: %#v", recovered) } a.stopPlugin(pluginManifest.PluginID) } func TestRegistryPersistenceErrorsAreObservable(t *testing.T) { reg, _ := openRegistry(t.TempDir()) reg.path = t.TempDir() if _, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash"); err == nil { t.Fatal("expected operation persistence error") } if len(reg.data.Operations) != 0 { t.Fatal("failed operation allocation remained in memory") } reg.path = filepath.Join(t.TempDir(), "registry.json") op, _, _, err := reg.operation("enable", "example.plugin", "key", 1, "rid", "hash") if err != nil { t.Fatal(err) } reg.path = t.TempDir() if _, err := reg.finishOperation(op, nil); err == nil { t.Fatal("expected operation finish persistence error") } reg.data.Audit = nil if err := reg.addAudit(auditEvent{Action: "test"}); err == nil { t.Fatal("expected audit persistence error") } } func TestUpgradeDoesNotCarryEndpointAcrossServiceModes(t *testing.T) { reg, _ := openRegistry(t.TempDir()) a := newApp(nil, reg, t.TempDir()) base := manifest.Manifest{PluginID: "mode.plugin", Name: "Mode", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT", Command: "service/plugin"}} old := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "old", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "old", Version: "1.0.0", Manifest: base}}} reg.data.Plugins[base.PluginID] = old newManifest := base newManifest.Version = "2.0.0" newManifest.Backend.Command = "" newInfo := packageInfo{Manifest: newManifest, Archive: []byte("external"), Files: map[string][]byte{"ui/index.html": []byte("mode")}} newManifest.Files = map[string]string{"ui/index.html": sha256Hex(newInfo.Files["ui/index.html"])} newInfo.Manifest = newManifest upgraded, err := a.installPackageMode(newInfo, true) if err != nil { t.Fatal(err) } if upgraded.Endpoint != "" { t.Fatalf("command endpoint leaked into external revision: %q", upgraded.Endpoint) } externalBase := base externalBase.Backend.Command = "" externalBase.Version = "2.0.0" reg.data.Plugins[base.PluginID] = pluginRecord{Manifest: externalBase, State: "disabled", ActiveRevision: "external", Endpoint: "http://127.0.0.1:59001", Revisions: []revision{{ID: "external", Version: "2.0.0", Manifest: externalBase}}} commandManifest := newManifest commandManifest.Version = "3.0.0" commandManifest.Backend.Command = "service/plugin" commandInfo := packageInfo{Manifest: commandManifest, Archive: []byte("command"), Files: map[string][]byte{"service/plugin": []byte("#!/bin/sh\nexit 0"), "ui/index.html": []byte("mode")}} commandManifest.Files = map[string]string{"service/plugin": sha256Hex(commandInfo.Files["service/plugin"]), "ui/index.html": sha256Hex(commandInfo.Files["ui/index.html"])} commandInfo.Manifest = commandManifest commandUpgraded, err := a.installPackageMode(commandInfo, true) if err != nil { t.Fatal(err) } if commandUpgraded.Endpoint != "" { t.Fatalf("external endpoint leaked into command revision: %q", commandUpgraded.Endpoint) } } func TestTwoFactorLoginCreatesAdminSession(t *testing.T) { var login2FACalled bool 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": _, _ = io.WriteString(w, `{"code":0,"data":{"requires_2fa":true,"temp_token":"TEMP"}}`) case "/api/v1/auth/login/2fa": login2FACalled = true _, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"ACCESS","refresh_token":"REFRESH"}}`) case "/api/v1/auth/me": _, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`) case "/api/v1/auth/logout": _, _ = io.WriteString(w, `{"code":0,"data":{}}`) default: _, _ = io.WriteString(w, `{"code":0,"data":{}}`) } })) defer coreServer.Close() reg, _ := openRegistry(t.TempDir()) a := newApp(core, reg, t.TempDir()) loginRec := httptest.NewRecorder() a.login(loginRec, httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))) if loginRec.Code != http.StatusOK || !strings.Contains(loginRec.Body.String(), "pending_token") { t.Fatalf("expected 2fa challenge: %d %s", loginRec.Code, loginRec.Body.String()) } var challenge struct { PendingToken string `json:"pending_token"` } if err := json.Unmarshal(loginRec.Body.Bytes(), &challenge); err != nil || challenge.PendingToken == "" { t.Fatalf("challenge token missing: %s", loginRec.Body.String()) } body := fmt.Sprintf(`{"pending_token":%q,"totp_code":"123456"}`, challenge.PendingToken) verifyRec := httptest.NewRecorder() a.login2FA(verifyRec, httptest.NewRequest(http.MethodPost, "/login/2fa", strings.NewReader(body))) if verifyRec.Code != http.StatusOK || !login2FACalled || len(verifyRec.Result().Cookies()) != 1 { t.Fatalf("2fa login failed: %d %s", verifyRec.Code, verifyRec.Body.String()) } } func (a *app) packageForTest(t *testing.T, id, version string) packageInfo { t.Helper() raw := validPackageVersion(t, id, version) info, err := a.inspectPackage(raw) if err != nil { t.Fatal(err) } return info }