Files
sub2api-add/plugins/plugin-admin/main_test.go
T
Qiufeng 028b505c36
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s
feat: add controlled plugin marketplace lifecycle
2026-08-28 00:51:34 +08:00

1157 lines
47 KiB
Go

package main
import (
"archive/zip"
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"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("<!doctype html><title>plugin</title>")
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 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())
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("<title>mode</title>")}}
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("<title>mode</title>")}}
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
}