chore: initialize standalone business plugin repository
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
CORE_BASE_URL=http://127.0.0.1:8080
|
||||
PLUGIN_ENV=production
|
||||
PLUGIN_HOST=127.0.0.1
|
||||
PLUGIN_PORT=8090
|
||||
PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin
|
||||
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin
|
||||
PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/
|
||||
PLUGIN_COOKIE_SECURE=false
|
||||
PLUGIN_COOKIE_SAMESITE=lax
|
||||
PLUGIN_FRAME_ANCESTORS='self'
|
||||
PLUGIN_ALLOW_UNSIGNED=false
|
||||
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
|
||||
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
|
||||
PLUGIN_TRUSTED_PUBLISHERS={}
|
||||
@@ -0,0 +1,15 @@
|
||||
.PHONY: test build check browser-check
|
||||
|
||||
test:
|
||||
go test ./... -count=1
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin .
|
||||
|
||||
check: test
|
||||
node --check ui/app.js
|
||||
git diff --check
|
||||
|
||||
browser-check:
|
||||
./test/run-browser-check.sh
|
||||
@@ -0,0 +1,76 @@
|
||||
# Sub2API Business Plugin Control Plane V1
|
||||
|
||||
This directory contains the independent administrator-only control plane for
|
||||
Business Plugins. It is deliberately separate from Sub2API Core and from the
|
||||
existing `.s2plugin` OpenAI OAuth transport runtime.
|
||||
|
||||
The control plane owns the plugin catalog, signed package verification,
|
||||
revision directories, lifecycle state, encrypted configuration metadata,
|
||||
menu preview/apply, and its own audit log. Core remains authoritative for
|
||||
users, administrator roles, balances, subscriptions, billing, and audit
|
||||
records. The service never connects to Core PostgreSQL/Redis and never sends a
|
||||
Core JWT or Admin Key to the browser.
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
CORE_BASE_URL=http://127.0.0.1:8080 \
|
||||
PLUGIN_HOST=127.0.0.1 PLUGIN_PORT=8090 \
|
||||
PLUGIN_REGISTRY_DIR=./data \
|
||||
PLUGIN_ENV=development \
|
||||
PLUGIN_ALLOW_UNSIGNED=true \
|
||||
PLUGIN_CONFIG_KEY=local-development-secret-at-least-32-chars \
|
||||
go run .
|
||||
```
|
||||
|
||||
`PLUGIN_ALLOW_UNSIGNED=true` is a development-only switch. Production
|
||||
packages must contain `signature.json`, use Ed25519, and match a trusted key
|
||||
from `PLUGIN_TRUSTED_PUBLISHERS` (a JSON object of key ID to base64 public
|
||||
key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
|
||||
generated secret in production and keep it stable across restarts so encrypted
|
||||
plugin configuration remains decryptable.
|
||||
|
||||
Open `/admin/` directly or expose the service through the reverse proxy in
|
||||
`deploy/`. The first login is the existing Core administrator login; no plugin
|
||||
user table is created. The control plane stores only a short-lived server-side
|
||||
session and encrypted plugin configuration.
|
||||
|
||||
## Control-plane endpoints
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
GET /readyz
|
||||
POST /login POST /login/2fa POST /logout
|
||||
GET /api/me GET /api/plugins GET /api/plugins/{id}
|
||||
POST /api/plugins/{id}/install (multipart field: package)
|
||||
POST /api/plugins/{id}/upgrade (multipart field: package)
|
||||
POST /api/plugins/{id}/enable|disable|rollback|uninstall
|
||||
GET|PUT /api/plugins/{id}/config
|
||||
POST /api/plugins/{id}/menu-preview|menu-apply
|
||||
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
|
||||
GET /api/audit
|
||||
```
|
||||
|
||||
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
|
||||
mutation returns an operation ID even when it completes synchronously. Failed
|
||||
installation and upgrade never replace the active revision. Uninstall is
|
||||
allowed only after disable and removes plugin files, not Core data.
|
||||
|
||||
## Plugin package
|
||||
|
||||
Packages are ZIP files with `manifest.json`, optional detached
|
||||
`signature.json`, and declared `ui/` files. A package may also include
|
||||
`service/` files when the control plane owns the plugin process; external
|
||||
service packages omit `backend.command` and require a configured loopback
|
||||
`service_url` before enabling. SHA-256 hashes in the manifest cover every
|
||||
declared file. Absolute paths, traversal, duplicate entries, symlinks,
|
||||
undeclared hashes, oversized files, unknown manifest fields, and untrusted
|
||||
publishers are rejected before staging. Installation uses a per-plugin
|
||||
revision directory and an atomic registry JSON update.
|
||||
|
||||
## Deliberate V1 limits
|
||||
|
||||
The control plane does not register Core routes, change Core migrations, run
|
||||
arbitrary proxy URLs, provide transparent iframe SSO, or move billing and
|
||||
subscription hot-path logic out of Core. A subscription manager remains a
|
||||
separate business plugin that consumes its own typed Core API adapter.
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
mkdir -p "$ROOT/bin"
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/plugin-admin" "$ROOT"
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"plugin_id": "qiu.plugin-admin",
|
||||
"name": "Business Plugin Control Plane",
|
||||
"version": "1.0.0",
|
||||
"core_api_baseline": "sub2api-0.1.183",
|
||||
"tested_core_versions": ["0.1.183"],
|
||||
"capabilities": ["plugin.admin.v1"],
|
||||
"backend": {
|
||||
"health_path": "/healthz",
|
||||
"readiness_path": "/readyz",
|
||||
"listen_env": "PLUGIN_PORT"
|
||||
},
|
||||
"ui": {
|
||||
"entrypoint": "ui/index.html",
|
||||
"menu": {
|
||||
"id": "qiu.plugin-admin",
|
||||
"label": "插件管理",
|
||||
"visibility": "admin",
|
||||
"sort_order": 190
|
||||
}
|
||||
},
|
||||
"publisher": { "key_id": "qiu-plugin-admin-dev" },
|
||||
"core_api_allowlist": [
|
||||
"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",
|
||||
"GET /api/v1/admin/settings"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
CORE_ORIGIN {
|
||||
handle_path /extensions/qiu.plugin-admin/* {
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
}
|
||||
|
||||
# Set PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin and
|
||||
# PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "qiu.plugin-admin",
|
||||
"label": "插件管理",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/",
|
||||
"visibility": "admin",
|
||||
"sort_order": 190
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
location /extensions/qiu.plugin-admin/ {
|
||||
proxy_pass http://127.0.0.1:8090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
|
||||
# Set PLUGIN_PUBLIC_BASE_PATH and PLUGIN_COOKIE_PATH to this same path.
|
||||
# Keep the service bound to loopback and expose it only through HTTPS.
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Sub2API Business Plugin Control Plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sub2api-plugin
|
||||
Group=sub2api-plugin
|
||||
WorkingDirectory=/opt/sub2api/plugin-admin
|
||||
EnvironmentFile=/etc/sub2api/plugin-admin.env
|
||||
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/sub2api/plugin-admin
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin
|
||||
|
||||
go 1.23
|
||||
@@ -0,0 +1,414 @@
|
||||
// Package manifest validates the standalone Business Plugin V1 manifest.
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
|
||||
versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
|
||||
methodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$`)
|
||||
pathParam = regexp.MustCompile(`^\{[a-zA-Z][a-zA-Z0-9_-]*\}$`)
|
||||
sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
// RequiredAllowlist contains the Core endpoints needed by every plugin BFF.
|
||||
// Domain-specific read/write endpoints must be appended by each plugin.
|
||||
var requiredAllowlist = []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",
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
CoreAPIBaseline string `json:"core_api_baseline"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
TestedCoreVersions []string `json:"tested_core_versions"`
|
||||
Backend Backend `json:"backend"`
|
||||
UI UI `json:"ui"`
|
||||
Publisher Publisher `json:"publisher"`
|
||||
CoreAPIAllowlist []string `json:"core_api_allowlist"`
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
HealthPath string `json:"health_path"`
|
||||
ReadinessPath string `json:"readiness_path"`
|
||||
ListenEnv string `json:"listen_env"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
Menu Menu `json:"menu"`
|
||||
}
|
||||
|
||||
type Menu struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Visibility string `json:"visibility"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Publisher struct {
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyID string `json:"key_id"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// Compatibility is the control-plane decision for the current Core version.
|
||||
type Compatibility struct {
|
||||
Compatible bool `json:"compatible"`
|
||||
Tested bool `json:"tested"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func Load(path string) (Manifest, []byte, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Manifest{}, nil, err
|
||||
}
|
||||
var m Manifest
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := rejectDuplicateJSONKeys(raw); err != nil {
|
||||
return Manifest{}, nil, err
|
||||
}
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return Manifest{}, nil, fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return Manifest{}, nil, errors.New("manifest contains trailing JSON")
|
||||
}
|
||||
return Manifest{}, nil, fmt.Errorf("decode manifest trailing data: %w", err)
|
||||
}
|
||||
if err := Validate(m); err != nil {
|
||||
return Manifest{}, nil, err
|
||||
}
|
||||
return m, raw, nil
|
||||
}
|
||||
|
||||
func Validate(m Manifest) error {
|
||||
if m.SchemaVersion != 1 {
|
||||
return errors.New("schema_version must be 1")
|
||||
}
|
||||
if !pluginIDPattern.MatchString(m.PluginID) || len(m.PluginID) > 160 {
|
||||
return errors.New("invalid plugin_id")
|
||||
}
|
||||
if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 {
|
||||
return errors.New("name is required and must be at most 160 characters")
|
||||
}
|
||||
if !versionPattern.MatchString(strings.TrimPrefix(m.Version, "v")) {
|
||||
return errors.New("invalid plugin version")
|
||||
}
|
||||
baseline := strings.TrimPrefix(strings.TrimPrefix(m.CoreAPIBaseline, "sub2api-"), "v")
|
||||
if !versionPattern.MatchString(baseline) {
|
||||
return errors.New("invalid core_api_baseline")
|
||||
}
|
||||
if len(m.Capabilities) == 0 {
|
||||
return errors.New("capabilities must contain at least one capability")
|
||||
}
|
||||
seenCaps := map[string]struct{}{}
|
||||
for _, capability := range m.Capabilities {
|
||||
if !pluginIDPattern.MatchString(capability) || len(capability) > 160 {
|
||||
return fmt.Errorf("invalid capability: %s", capability)
|
||||
}
|
||||
if _, ok := seenCaps[capability]; ok {
|
||||
return fmt.Errorf("duplicate capability: %s", capability)
|
||||
}
|
||||
seenCaps[capability] = struct{}{}
|
||||
}
|
||||
for _, version := range m.TestedCoreVersions {
|
||||
if !versionPattern.MatchString(strings.TrimPrefix(version, "v")) {
|
||||
return fmt.Errorf("invalid tested_core_versions entry: %s", version)
|
||||
}
|
||||
}
|
||||
if err := validateEndpointPath(m.Backend.HealthPath); err != nil {
|
||||
return fmt.Errorf("backend.health_path: %w", err)
|
||||
}
|
||||
if err := validateEndpointPath(m.Backend.ReadinessPath); err != nil {
|
||||
return fmt.Errorf("backend.readiness_path: %w", err)
|
||||
}
|
||||
if m.Backend.Command != "" {
|
||||
if err := validateRelativePath(m.Backend.Command); err != nil || !strings.HasPrefix(strings.ReplaceAll(m.Backend.Command, "\\", "/"), "service/") {
|
||||
return errors.New("backend.command must be a safe path under service/")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.Backend.ListenEnv) == "" || !regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`).MatchString(m.Backend.ListenEnv) {
|
||||
return errors.New("backend.listen_env is invalid")
|
||||
}
|
||||
if err := validateUIEntrypoint(m.UI.Entrypoint); err != nil {
|
||||
return fmt.Errorf("ui.entrypoint: %w", err)
|
||||
}
|
||||
if m.UI.Menu.ID != m.PluginID || strings.TrimSpace(m.UI.Menu.Label) == "" || len(m.UI.Menu.Label) > 160 || m.UI.Menu.Visibility != "admin" || m.UI.Menu.SortOrder < 0 {
|
||||
return errors.New("ui.menu must bind to plugin_id, use admin visibility, and have a valid label/order")
|
||||
}
|
||||
if m.UI.Menu.URL != "" {
|
||||
u, err := url.Parse(strings.TrimSpace(m.UI.Menu.URL))
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return errors.New("ui.menu.url must be an absolute http(s) URL without credentials or query")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(m.Publisher.KeyID) == "" || len(m.Publisher.KeyID) > 160 {
|
||||
return errors.New("publisher.key_id is required")
|
||||
}
|
||||
if len(m.CoreAPIAllowlist) < len(requiredAllowlist) {
|
||||
return fmt.Errorf("core_api_allowlist must contain at least %d entries", len(requiredAllowlist))
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, entry := range m.CoreAPIAllowlist {
|
||||
if err := validateAllowlistEntry(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[entry]; ok {
|
||||
return fmt.Errorf("duplicate allowlist entry: %s", entry)
|
||||
}
|
||||
seen[entry] = struct{}{}
|
||||
}
|
||||
for _, required := range requiredAllowlist {
|
||||
if _, ok := seen[required]; !ok {
|
||||
return fmt.Errorf("missing required allowlist entry: %s", required)
|
||||
}
|
||||
}
|
||||
for path, hash := range m.Files {
|
||||
if err := validateRelativePath(path); err != nil || !sha256Pattern.MatchString(hash) {
|
||||
return fmt.Errorf("invalid file hash declaration: %s", path)
|
||||
}
|
||||
}
|
||||
if len(m.Files) > 0 && strings.HasPrefix(m.UI.Entrypoint, "ui/") {
|
||||
if _, ok := m.Files[m.UI.Entrypoint]; !ok {
|
||||
return errors.New("ui.entrypoint is missing from files")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginPath(value string) error {
|
||||
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
|
||||
return errors.New("must be a safe plugin path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEndpointPath(value string) error {
|
||||
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.ContainsAny(p, "*?#") {
|
||||
return errors.New("must be a safe absolute endpoint path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRelativePath(value string) error {
|
||||
p := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
||||
// Reject rooted paths, traversal, URL/drive syntax, and glob/query fragments.
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") || strings.Contains(p, "..") || strings.Contains(p, "*") || strings.Contains(p, "?") || strings.Contains(p, "#") || strings.Contains(p, ":") {
|
||||
return errors.New("must be a safe relative path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUIEntrypoint(value string) error {
|
||||
p := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
if p == "/admin" || p == "/admin/" {
|
||||
return nil
|
||||
}
|
||||
return validateRelativePath(p)
|
||||
}
|
||||
|
||||
func validateAllowlistEntry(entry string) error {
|
||||
parts := strings.Fields(entry)
|
||||
if len(parts) != 2 || !methodPattern.MatchString(parts[0]) {
|
||||
return fmt.Errorf("invalid core_api_allowlist entry: %s", entry)
|
||||
}
|
||||
p := parts[1]
|
||||
if !strings.HasPrefix(p, "/api/v1/") || strings.ContainsAny(p, "?#*") || strings.Contains(p, "//") || strings.Contains(p, "..") {
|
||||
return fmt.Errorf("core_api_allowlist entry must be an exact Core path: %s", entry)
|
||||
}
|
||||
for _, segment := range strings.Split(strings.TrimPrefix(p, "/"), "/") {
|
||||
if strings.Contains(segment, "{") || strings.Contains(segment, "}") {
|
||||
if !pathParam.MatchString(segment) {
|
||||
return fmt.Errorf("invalid path parameter in allowlist entry: %s", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifySignature(manifestBytes, signatureBytes, publicKeyBytes []byte) error {
|
||||
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
var signature Signature
|
||||
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&signature); err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
return errors.New("signature contains trailing JSON")
|
||||
}
|
||||
if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" {
|
||||
return errors.New("signature must use ed25519 and include key_id")
|
||||
}
|
||||
publicKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(publicKeyBytes)))
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
return errors.New("invalid base64 ed25519 public key")
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(signature.Signature)
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return errors.New("invalid base64 ed25519 signature")
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(publicKey), manifestBytes, sig) {
|
||||
return errors.New("manifest signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifyKeyID(signatureBytes []byte, expectedKeyID string) error {
|
||||
if err := rejectDuplicateJSONKeys(signatureBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
var signature Signature
|
||||
dec := json.NewDecoder(strings.NewReader(string(signatureBytes)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&signature); err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
return errors.New("signature contains trailing JSON")
|
||||
}
|
||||
if strings.TrimSpace(expectedKeyID) == "" || signature.KeyID != expectedKeyID {
|
||||
return errors.New("signature key_id does not match manifest publisher")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectDuplicateJSONKeys performs a token-level walk because encoding/json
|
||||
// otherwise accepts duplicate object members and silently keeps the last one.
|
||||
func rejectDuplicateJSONKeys(raw []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
var walk func() error
|
||||
walk = func() error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch delim {
|
||||
case '{':
|
||||
seen := map[string]struct{}{}
|
||||
for dec.More() {
|
||||
key, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return errors.New("object member name must be a string")
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
return fmt.Errorf("duplicate JSON object key: %s", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if err := walk(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if end != json.Delim('}') {
|
||||
return errors.New("invalid JSON object")
|
||||
}
|
||||
case '[':
|
||||
for dec.More() {
|
||||
if err := walk(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
end, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if end != json.Delim(']') {
|
||||
return errors.New("invalid JSON array")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(); err != nil {
|
||||
return fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("JSON contains trailing data")
|
||||
}
|
||||
return fmt.Errorf("invalid JSON trailing data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RequiredAllowlist() []string { return append([]string(nil), requiredAllowlist...) }
|
||||
|
||||
func (m Manifest) SortedCapabilities() []string {
|
||||
out := append([]string(nil), m.Capabilities...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// EvaluateCompatibility applies the V1 rule that an untested Core is compatible
|
||||
// but must remain disabled until an administrator explicitly accepts it.
|
||||
func (m Manifest) EvaluateCompatibility(coreVersion string) Compatibility {
|
||||
coreVersion = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(coreVersion), "sub2api-"), "v")
|
||||
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m.CoreAPIBaseline), "sub2api-"), "v")
|
||||
if coreVersion == "" || baseline == "" || coreVersion != baseline {
|
||||
return Compatibility{Status: "incompatible", Message: "Core version does not match plugin baseline"}
|
||||
}
|
||||
tested := false
|
||||
for _, version := range m.TestedCoreVersions {
|
||||
v := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "sub2api-"), "v")
|
||||
if v == coreVersion {
|
||||
tested = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !tested {
|
||||
return Compatibility{Compatible: true, Status: "untested", Message: "Core version is compatible but not tested"}
|
||||
}
|
||||
return Compatibility{Compatible: true, Tested: true, Status: "compatible", Message: "Core version is tested"}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validManifest() Manifest {
|
||||
return Manifest{
|
||||
SchemaVersion: 1, PluginID: "qiu.plugin-admin", Name: "Plugin Admin", Version: "1.0.0",
|
||||
CoreAPIBaseline: "sub2api-0.1.183", Capabilities: []string{"plugin.admin.v1", "diagnostics.v1"},
|
||||
TestedCoreVersions: []string{"0.1.183"},
|
||||
Backend: Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", ListenEnv: "PLUGIN_PORT"},
|
||||
UI: UI{Entrypoint: "/admin/", Menu: Menu{ID: "qiu.plugin-admin", Label: "Plugin Admin", Visibility: "admin", SortOrder: 200}},
|
||||
Publisher: Publisher{KeyID: "publisher-key"}, CoreAPIAllowlist: RequiredAllowlist(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestAndRejectsUnsafeEntries(t *testing.T) {
|
||||
m := validManifest()
|
||||
if err := Validate(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*Manifest){
|
||||
"duplicate allowlist": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, m.CoreAPIAllowlist[0]) },
|
||||
"wildcard": func(m *Manifest) { m.CoreAPIAllowlist = append(m.CoreAPIAllowlist, "GET /api/v1/admin/*") },
|
||||
"menu mismatch": func(m *Manifest) { m.UI.Menu.ID = "other.plugin" },
|
||||
"traversal": func(m *Manifest) { m.UI.Entrypoint = "/admin/../secret" },
|
||||
"entrypoint URL": func(m *Manifest) { m.UI.Entrypoint = "https://example.invalid/ui.js" },
|
||||
"file drive path": func(m *Manifest) { m.Files = map[string]string{"C:/plugin.js": strings.Repeat("a", 64)} },
|
||||
"file traversal": func(m *Manifest) { m.Files = map[string]string{"ui/../plugin.js": strings.Repeat("a", 64)} },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := m
|
||||
mutate(&candidate)
|
||||
if err := Validate(candidate); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, raw := range map[string]string{
|
||||
"unknown": `{"schema_version":1,"extra":true}`,
|
||||
"trailing": `{"schema_version":1} {}`,
|
||||
"duplicate": `{"schema_version":1,"schema_version":1}`,
|
||||
} {
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := Load(path); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsNestedDuplicateJSONKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw := `{"schema_version":1,"backend":{"health_path":"/healthz","health_path":"/readyz"}}`
|
||||
path := filepath.Join(dir, "nested-duplicate.json")
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := Load(path); err == nil {
|
||||
t.Fatal("expected nested duplicate key error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureAndKeyID(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestBytes := []byte(`{"schema_version":1}`)
|
||||
signature := Signature{Algorithm: "ed25519", KeyID: "publisher-key", Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestBytes))}
|
||||
signatureBytes, err := json.Marshal(signature)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKeyBytes := []byte(base64.StdEncoding.EncodeToString(publicKey))
|
||||
if err := VerifyKeyID(signatureBytes, "publisher-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifySignature(manifestBytes, signatureBytes, publicKeyBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifySignature([]byte(`{"schema_version":2}`), signatureBytes, publicKeyBytes); err == nil {
|
||||
t.Fatal("expected tamper failure")
|
||||
}
|
||||
if err := VerifyKeyID([]byte(strings.TrimSuffix(string(signatureBytes), "}")+"}{}"), "publisher-key"); err == nil {
|
||||
t.Fatal("expected trailing signature failure")
|
||||
}
|
||||
duplicate := []byte(`{"algorithm":"ed25519","algorithm":"ed25519","key_id":"publisher-key","signature":""}`)
|
||||
if err := VerifyKeyID(duplicate, "publisher-key"); err == nil {
|
||||
t.Fatal("expected duplicate signature key failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibility(t *testing.T) {
|
||||
m := validManifest()
|
||||
if got := m.EvaluateCompatibility("sub2api-0.1.183"); !got.Compatible || !got.Tested || got.Status != "compatible" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
m.TestedCoreVersions = nil
|
||||
if got := m.EvaluateCompatibility("0.1.183"); !got.Compatible || got.Tested || got.Status != "untested" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
if got := m.EvaluateCompatibility("0.1.184"); got.Compatible || got.Status != "incompatible" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,990 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.awaioi.com/awaioi/sub2api-add/plugins/plugin-admin/internal/manifest"
|
||||
)
|
||||
|
||||
func testCore(t *testing.T, handler http.Handler) (*coreClient, *httptest.Server) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
client, err := newCoreClient(server.URL)
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return client, server
|
||||
}
|
||||
|
||||
func adminSession(a *app) *http.Cookie {
|
||||
now := time.Now()
|
||||
id := "session"
|
||||
a.sessions[id] = session{AccessToken: "ACCESS", RefreshToken: "REFRESH", CSRFToken: "CSRF", User: map[string]any{"id": float64(1), "role": "admin", "email": "admin@example.com"}, CreatedAt: now, LastSeen: now}
|
||||
a.sessionLocks[id] = &sync.Mutex{}
|
||||
return &http.Cookie{Name: sessionCookieName, Value: id}
|
||||
}
|
||||
|
||||
func validPackage(t *testing.T, id string) []byte {
|
||||
return validPackageVersion(t, id, "1.0.0")
|
||||
}
|
||||
|
||||
func validPackageVersion(t *testing.T, id, version string) []byte {
|
||||
t.Helper()
|
||||
ui := []byte("<!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 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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { chromium } from 'playwright'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18090'
|
||||
const entry = `${origin}/admin/`
|
||||
const outputDir = path.resolve(process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/plugin-admin')
|
||||
const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com'
|
||||
const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true })
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
for (const width of [425, 900, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
|
||||
const responseLeaks = []
|
||||
page.on('response', async (response) => {
|
||||
if (!response.headers()['content-type']?.includes('application/json')) return
|
||||
try {
|
||||
const body = await response.text()
|
||||
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
|
||||
} catch (_) {}
|
||||
})
|
||||
await page.goto(entry, { waitUntil: 'networkidle' })
|
||||
await page.getByLabel('邮箱').fill(email)
|
||||
await page.getByLabel('密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await page.getByRole('heading', { name: '已登记插件' }).waitFor()
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
|
||||
const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
|
||||
if (!buttons) throw new Error(`control collapsed at ${width}px`)
|
||||
await page.waitForTimeout(50)
|
||||
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
|
||||
await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true })
|
||||
await page.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
PLUGIN_BROWSER_ORIGIN=${PLUGIN_BROWSER_ORIGIN:-http://127.0.0.1:18090} \
|
||||
PLUGIN_SCREENSHOT_DIR=${PLUGIN_SCREENSHOT_DIR:-$ROOT/.screenshots/plugin-admin} \
|
||||
node "$ROOT/test/browser-check.mjs"
|
||||
@@ -0,0 +1,65 @@
|
||||
(() => {
|
||||
'use strict'
|
||||
const base = (window.__PLUGIN_BASE_PATH__ || '').replace(/\/$/, '')
|
||||
const $ = (selector) => document.querySelector(selector)
|
||||
let csrf = ''
|
||||
let pendingToken = ''
|
||||
let configPluginId = ''
|
||||
const notice = (message, error = false) => {
|
||||
const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice'
|
||||
}
|
||||
const api = async (path, options = {}) => {
|
||||
const headers = new Headers(options.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
|
||||
if (csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', csrf)
|
||||
const response = await fetch(`${base}${path}`, { ...options, headers, credentials: 'same-origin' })
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`)
|
||||
return data
|
||||
}
|
||||
const setLoggedIn = (user) => {
|
||||
$('#login-panel').hidden = !!user
|
||||
$('#app-panel').hidden = !user
|
||||
$('#logout').hidden = !user
|
||||
$('#operator').textContent = user ? (user.email || user.username || '管理员') : ''
|
||||
}
|
||||
const login = async (event) => {
|
||||
event.preventDefault(); $('#login-error').textContent = ''
|
||||
const body = Object.fromEntries(new FormData(event.currentTarget).entries())
|
||||
try {
|
||||
const result = await api('/login', { method: 'POST', body: JSON.stringify(body) })
|
||||
if (result.requires_2fa) { pendingToken = result.pending_token; $('#login-form').hidden = true; $('#twofa-form').hidden = false; return }
|
||||
csrf = result.csrf_token; setLoggedIn(result.user); await loadAll()
|
||||
} catch (error) { $('#login-error').textContent = error.message }
|
||||
}
|
||||
const login2fa = async (event) => {
|
||||
event.preventDefault(); $('#login-error').textContent = ''
|
||||
const body = Object.fromEntries(new FormData(event.currentTarget).entries()); body.pending_token = pendingToken
|
||||
try { const result = await api('/login/2fa', { method: 'POST', body: JSON.stringify(body) }); csrf = result.csrf_token; setLoggedIn(result.user); await loadAll() } catch (error) { $('#login-error').textContent = error.message }
|
||||
}
|
||||
const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true }
|
||||
const badge = (state) => `<span class="badge badge-${String(state || '').replace(/[^a-z_]/g, '')}">${escapeHtml(state || 'unknown')}</span>`
|
||||
const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]))
|
||||
const loadPlugins = async () => {
|
||||
const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = ''
|
||||
if (!data.items || !data.items.length) { root.innerHTML = '<div class="empty">还没有登记业务插件</div>'; return }
|
||||
for (const plugin of data.items) {
|
||||
const card = document.createElement('article'); card.className = 'plugin-card'
|
||||
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div>${badge(plugin.state)}</div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>卸载</button></div>`
|
||||
root.appendChild(card)
|
||||
}
|
||||
}
|
||||
const loadAudit = async () => { const data = await api('/api/audit'); const root = $('#audit'); root.textContent = ''; for (const item of (data.items || []).slice().reverse()) { const row = document.createElement('div'); row.className = 'audit-row'; row.innerHTML = `<span>${escapeHtml(item.action)}</span><span class="mono">${escapeHtml(item.plugin_id || '-')}</span><span>${escapeHtml(item.result)}</span><time>${escapeHtml(item.time)}</time>`; root.appendChild(row) } }
|
||||
const loadAll = async () => { try { await Promise.all([loadPlugins(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
|
||||
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } }
|
||||
const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } }
|
||||
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
|
||||
$('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' })
|
||||
$('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' })
|
||||
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) })
|
||||
api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null))
|
||||
})()
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>插件管理</title>
|
||||
<link rel="stylesheet" href="../styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">SUB2API EXTENSIONS</p>
|
||||
<h1>插件管理</h1>
|
||||
<p class="muted">独立业务插件控制面</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="operator" class="operator"></span>
|
||||
<button id="logout" class="button button-quiet" hidden>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="login-panel" class="panel auth-panel">
|
||||
<h2>管理员登录</h2>
|
||||
<p class="muted">使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。</p>
|
||||
<form id="login-form" class="form-grid">
|
||||
<label>邮箱<input name="email" type="email" autocomplete="username" required></label>
|
||||
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button" type="submit">登录</button>
|
||||
</form>
|
||||
<form id="twofa-form" class="form-grid" hidden>
|
||||
<label>验证码<input name="totp_code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required></label>
|
||||
<button class="button" type="submit">验证并继续</button>
|
||||
</form>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="app-panel" hidden>
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>已登记插件</h2>
|
||||
<p class="muted">安装包会先验签、校验哈希和 Core 兼容性,再进入停用状态。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="file-button">安装插件<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
|
||||
<button id="refresh" class="button button-secondary" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="notice" class="notice" role="status"></p>
|
||||
<div id="plugins" class="plugin-list"></div>
|
||||
<section class="panel audit-panel">
|
||||
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
|
||||
<div id="audit" class="audit-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="config-dialog" class="config-dialog">
|
||||
<form id="config-form" method="dialog" class="config-form">
|
||||
<div class="section-heading"><h2>插件配置</h2><button id="config-close" class="button button-quiet" type="button">关闭</button></div>
|
||||
<p id="config-plugin" class="muted mono"></p>
|
||||
<label>服务地址<input name="service_url" type="url" placeholder="http://127.0.0.1:18090" required></label>
|
||||
<label>菜单地址<input name="public_url" type="url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/"></label>
|
||||
<p class="muted">密钥只在服务端加密保存,页面不会回显原值。</p>
|
||||
<div class="dialog-actions"><button id="config-cancel" class="button button-secondary" type="button">取消</button><button class="button" type="submit">保存配置</button></div>
|
||||
<p id="config-error" class="error" role="alert"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>window.__PLUGIN_BASE_PATH__ = __PLUGIN_BASE_PATH_JSON__;</script>
|
||||
<script src="../app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f6f8; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
.shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
|
||||
.topbar, .toolbar, .section-heading, .plugin-title, .card-actions, .top-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d9dee5; }
|
||||
.eyebrow { color: #5a6877; font-size: 11px; letter-spacing: .12em; margin: 0 0 6px; }
|
||||
h1, h2, h3, p { margin-top: 0; } h1 { font-size: 28px; margin-bottom: 4px; } h2 { font-size: 18px; margin-bottom: 8px; } h3 { margin-bottom: 4px; font-size: 17px; }
|
||||
.muted { color: #687585; font-size: 13px; } .operator { color: #475569; font-size: 13px; }
|
||||
.panel { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; box-shadow: 0 2px 8px rgb(15 23 42 / 4%); }
|
||||
.auth-panel { max-width: 480px; margin: 48px auto 0; padding: 24px; }
|
||||
.form-grid { display: grid; gap: 14px; margin-top: 20px; } label { display: grid; gap: 6px; color: #334155; font-size: 13px; }
|
||||
input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; border-radius: 4px; background: #fff; color: inherit; font: inherit; } input:focus { outline: 2px solid #a7c7ff; outline-offset: 1px; }
|
||||
.button, .file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 14px; border: 1px solid #2563eb; border-radius: 4px; background: #2563eb; color: #fff; cursor: pointer; font: inherit; font-size: 13px; white-space: nowrap; }
|
||||
.button:hover { background: #1d4ed8; } .button:disabled { opacity: .45; cursor: not-allowed; } .button-secondary { background: #fff; color: #1e40af; border-color: #b9c8dc; } .button-secondary:hover, .button-quiet:hover { background: #f3f6fa; } .button-quiet { background: transparent; color: #334155; border-color: transparent; } .button-danger { background: #fff; color: #b42318; border-color: #e1aaa4; }
|
||||
.file-button input { display: none; }
|
||||
#app-panel { margin-top: 32px; } .toolbar { margin-bottom: 18px; } .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.notice { min-height: 20px; margin: 8px 0 14px; font-size: 13px; color: #17603a; } .error { color: #b42318; }
|
||||
.plugin-list { display: grid; gap: 12px; }
|
||||
.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.badge { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #334155; background: #e8edf2; font-size: 12px; } .badge-healthy { background: #d9f6e5; color: #146c43; } .badge-error, .badge-incompatible { background: #fde1df; color: #9b1c16; } .badge-starting, .badge-draining { background: #fff0c2; color: #7a4d00; }
|
||||
.meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0 12px; } .meta div { min-width: 0; } dt { color: #687585; font-size: 12px; margin-bottom: 4px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; } .plugin-error { min-height: 18px; margin-bottom: 12px; font-size: 12px; color: #b42318; }
|
||||
.card-actions { justify-content: flex-start; flex-wrap: wrap; } .empty { padding: 32px; text-align: center; color: #687585; border: 1px dashed #c7d0da; border-radius: 6px; background: #fff; }
|
||||
.audit-panel { margin-top: 24px; padding: 18px; } .audit-list { display: grid; gap: 1px; } .audit-row { display: grid; grid-template-columns: 1.2fr 1.3fr .8fr 1.7fr; gap: 10px; padding: 9px 0; border-top: 1px solid #edf0f3; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.config-dialog { width: min(460px, calc(100% - 24px)); padding: 0; border: 0; border-radius: 6px; box-shadow: 0 18px 60px rgb(15 23 42 / 24%); } .config-dialog::backdrop { background: rgb(15 23 42 / 42%); } .config-form { display: grid; gap: 14px; padding: 22px; } .config-form .section-heading { margin-bottom: 4px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } }
|
||||
Reference in New Issue
Block a user