1157 lines
47 KiB
Go
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
|
|
}
|