mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations The V1 plugin system shipped 14 tables, an append-only revision log per relationship, a capability-snapshot compiler, a per-task execution manifest pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers, and a bundled catalog — and never left its feature flag. It holds no production data, so this removes it outright instead of migrating it forward. What replaces it, per the design in MUL-6338: - A plugin relates to Multica in exactly three ways. Action (plugin calls Multica), Hook (Multica calls plugin), Resource (a static contribution with no call at all). "Who triggers" and "what capability is called" are orthogonal: a hook is declared once and lists which triggers may invoke it. - We never execute third-party code. A plugin runs only in a sandboxed iframe in the user's browser or on the author's own server. - Distribution is "install by URL, admin reads the scopes". No signature, no trust tier, no publisher verification — the scope list IS the trust decision. This change is the foundation, with no user-visible surface yet: - Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move into pkg/remotemcp, where they belonged; the protocol layer and daemon broker survive intact and dormant, ready to be rewired for the agent trigger. - Drops all 14 plugin_* tables, their triggers, the manifest-composition function, and agent_task_queue.plugin_execution_manifest_id. Adds three: plugin_installation (one row per workspace+plugin, holding the manifest snapshot the admin consented to), plugin_storage, plugin_secret. - Defines the manifest schema in full, once — surfaces, hooks with their triggers and events, resources, and a closed scope list. Parsing stays strict (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does not ship yet fail installation loudly rather than installing half-working. - Two-step install: preview parses and returns the scopes without writing, then install requires consent matching the manifest exactly. Manifest fetches reuse the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for self-hosting and plugin authors. - Storage quotas are enforced on write and never by eviction. Secrets live in a separate encrypted table with no read path that returns a value. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup Every migration that builds an index CONCURRENTLY must be registered in concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\ BuildHasCleanup caught all three new plugin index migrations. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry From the review on #7144, plus one contract change the surface design forced. - Storage quotas were accounted in characters and enforced in bytes. The usage query and the size_bytes it reports now use octet_length, and so do the plugin_storage column checks. A UTF-8 value could otherwise spend up to a quarter of the budget it appeared to. - manifest version had no length bound while plugin_installation.version caps at 64, so a legal-but-long semver failed at INSERT as an opaque install error instead of a parse error naming the field. - Upgrading pruned config values the new manifest dropped but left the matching plugin_secret rows. Ciphertext nothing can reach is exactly the residue pruneConfig exists to prevent; upgrade now prunes both in one transaction. - SetConfig wrote secrets and config across two tables with no transaction, so a failed config write could leave a saved form half-applied. - A duplicate first install mapped a unique-index violation to 502; it is 409. - net: is now an exact host match. It was a suffix match in the hook transport check while the same scope becomes an exact-host CSP connect-src in the surface runtime — one scope string cannot mean two things across the two places the consent screen claims to describe. - surfaces[].entry must be a .js/.mjs script, not an HTML document. The host generates the surface's document so it can attach the CSP derived from net:; a plugin-authored document would carry its own server's policy and net: would be a claim rather than a control. Tests: the three storage limits get a canonical unit matrix plus DB-backed coverage of the byte counting and the exclude-the-candidate-key replacement semantics; upgrade-time secret pruning is pinned end to end (verified failing without the fix); and the admin-only routes now have a real middleware-level 403 check, which every direct-handler test bypassed. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
455 lines
20 KiB
Go
455 lines
20 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
|
"github.com/multica-ai/multica/server/pkg/plugincontract"
|
|
)
|
|
|
|
// The reference manifest for handler tests declares only capabilities this
|
|
// build ships. Contribution kinds that are still gated are covered by the
|
|
// capability matrix in pkg/plugincontract; repeating them here would just
|
|
// re-run that matrix through HTTP.
|
|
const handlerTestManifest = `{
|
|
"manifest_version": 1,
|
|
"key": "com.example.hello",
|
|
"name": "Hello Panel",
|
|
"version": "1.0.0",
|
|
"author": { "name": "example" },
|
|
"scopes": ["issues:read", "comments:write", "storage:user"],
|
|
"config": {
|
|
"repo": { "type": "string", "label": "Repo", "required": true },
|
|
"token": { "type": "secret", "label": "Token" }
|
|
},
|
|
"contributes": {
|
|
"surfaces": [{ "key": "hello", "type": "issue_panel", "name": "Hello", "entry": "ui/main.js" }]
|
|
}
|
|
}`
|
|
|
|
func pluginHandlerRequest(method, path string, body []byte, params map[string]string) *http.Request {
|
|
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
request.Header.Set("X-User-ID", testUserID)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
routeContext := chi.NewRouteContext()
|
|
for key, value := range params {
|
|
routeContext.URLParams.Add(key, value)
|
|
}
|
|
return request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
|
|
}
|
|
|
|
func writeLocalPluginManifest(t *testing.T, root, manifest string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Join(root, "hello"), 0o755); err != nil {
|
|
t.Fatalf("create local plugin dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "hello", plugincontract.ManifestFilename), []byte(manifest), 0o644); err != nil {
|
|
t.Fatalf("write local manifest: %v", err)
|
|
}
|
|
}
|
|
|
|
// withLocalPluginSource points the service at a temp MULTICA_PLUGIN_DIR and
|
|
// enables every capability, so these tests exercise the HTTP surface rather
|
|
// than the staged-rollout gate.
|
|
func withLocalPluginSource(t *testing.T, manifest string) string {
|
|
t.Helper()
|
|
return withLocalPluginSourceIn(t, t.TempDir(), manifest)
|
|
}
|
|
|
|
// withLocalPluginSourceIn takes the root explicitly so an upgrade test can
|
|
// rewrite the manifest in place and install again from the same source URL.
|
|
func withLocalPluginSourceIn(t *testing.T, root string, manifest string) string {
|
|
t.Helper()
|
|
writeLocalPluginManifest(t, root, manifest)
|
|
|
|
previousDir := testHandler.PluginService.LocalDir
|
|
previousHost := testHandler.PluginService.Host
|
|
previousSecrets := testHandler.PluginService.Secrets
|
|
testHandler.PluginService.LocalDir = root
|
|
testHandler.PluginService.Host = plugincontract.Capabilities{
|
|
SurfaceTypes: map[string]bool{plugincontract.SurfaceIssuePanel: true, plugincontract.SurfaceSidebarPanel: true, plugincontract.SurfaceModal: true},
|
|
HookTriggers: map[string]bool{plugincontract.TriggerUI: true, plugincontract.TriggerManual: true, plugincontract.TriggerAgent: true, plugincontract.TriggerEvent: true},
|
|
HookTransport: map[string]bool{plugincontract.TransportHTTP: true, plugincontract.TransportMCP: true},
|
|
ResourceTypes: map[string]bool{plugincontract.ResourceSkill: true},
|
|
}
|
|
box, err := secretbox.New(bytes.Repeat([]byte{7}, 32))
|
|
if err != nil {
|
|
t.Fatalf("secretbox.New: %v", err)
|
|
}
|
|
testHandler.PluginService.Secrets = box
|
|
t.Cleanup(func() {
|
|
testHandler.PluginService.LocalDir = previousDir
|
|
testHandler.PluginService.Host = previousHost
|
|
testHandler.PluginService.Secrets = previousSecrets
|
|
})
|
|
return service.LocalSourcePrefix + "hello"
|
|
}
|
|
|
|
func cleanupPluginInstallations(t *testing.T) {
|
|
t.Helper()
|
|
remove := func() {
|
|
ctx := context.Background()
|
|
testPool.Exec(ctx, `DELETE FROM plugin_storage WHERE installation_id IN (SELECT id FROM plugin_installation WHERE workspace_id = $1)`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM plugin_secret WHERE installation_id IN (SELECT id FROM plugin_installation WHERE workspace_id = $1)`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM plugin_installation WHERE workspace_id = $1`, testWorkspaceID)
|
|
}
|
|
remove()
|
|
t.Cleanup(remove)
|
|
}
|
|
|
|
func TestPluginManagementRequiresPluginsV1(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, false)
|
|
for name, handler := range map[string]http.HandlerFunc{
|
|
"list": testHandler.ListPlugins,
|
|
"preview": testHandler.PreviewPlugin,
|
|
"install": testHandler.InstallPlugin,
|
|
"configure": testHandler.ConfigurePlugin,
|
|
"enable": testHandler.EnablePlugin,
|
|
"disable": testHandler.DisablePlugin,
|
|
"uninstall": testHandler.UninstallPlugin,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
handler(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", []byte(`{}`), map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginPreviewShowsScopesWithoutInstalling(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
source := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
body, _ := json.Marshal(map[string]string{"source_url": source})
|
|
testHandler.PreviewPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/preview", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("preview status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
var preview service.PluginPreview
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &preview); err != nil {
|
|
t.Fatalf("decode preview: %v", err)
|
|
}
|
|
if len(preview.Scopes) != 3 || preview.Installed {
|
|
t.Fatalf("unexpected preview: %+v", preview)
|
|
}
|
|
if len(preview.ConfigSchema) != 2 || preview.ConfigSchema[0].Key != "repo" || preview.ConfigSchema[1].Type != plugincontract.ConfigSecret {
|
|
t.Fatalf("unexpected config schema: %+v", preview.ConfigSchema)
|
|
}
|
|
|
|
// Preview must be side-effect free: the consent screen has to be able to
|
|
// show scopes before anything exists to revoke.
|
|
listRecorder := httptest.NewRecorder()
|
|
testHandler.ListPlugins(listRecorder, pluginHandlerRequest(http.MethodGet, "/plugins", nil, map[string]string{"id": testWorkspaceID}))
|
|
if listRecorder.Code != http.StatusOK {
|
|
t.Fatalf("list status=%d body=%s", listRecorder.Code, listRecorder.Body.String())
|
|
}
|
|
var list struct {
|
|
Plugins []map[string]any `json:"plugins"`
|
|
}
|
|
if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(list.Plugins) != 0 {
|
|
t.Fatalf("preview created an installation: %v", list.Plugins)
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallRequiresExactConsent(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
source := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
partial, _ := json.Marshal(map[string]any{"source_url": source, "granted_scopes": []string{"issues:read"}})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", partial, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("partial consent status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
extra, _ := json.Marshal(map[string]any{
|
|
"source_url": source,
|
|
"granted_scopes": []string{"issues:read", "comments:write", "storage:user", "issues:write"},
|
|
})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", extra, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("over-consent status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallConfigureAndUninstall(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
source := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
install, _ := json.Marshal(map[string]any{
|
|
"source_url": source,
|
|
"granted_scopes": []string{"issues:read", "comments:write", "storage:user"},
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", install, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("install status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var installed struct {
|
|
ID string `json:"id"`
|
|
PluginKey string `json:"plugin_key"`
|
|
Enabled bool `json:"enabled"`
|
|
Config map[string]any `json:"config"`
|
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
|
|
t.Fatalf("decode installation: %v", err)
|
|
}
|
|
if installed.ID == "" || installed.PluginKey != "com.example.hello" || !installed.Enabled {
|
|
t.Fatalf("unexpected installation: %+v", installed)
|
|
}
|
|
|
|
params := map[string]string{"id": testWorkspaceID, "installationId": installed.ID}
|
|
configure, _ := json.Marshal(map[string]any{"values": map[string]any{"repo": "multica-ai/multica", "token": "sk-super-secret"}})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.ConfigurePlugin(recorder, pluginHandlerRequest(http.MethodPut, "/plugins/config", configure, params))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("configure status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
// The secret must not come back in any form: not in `config`, not masked,
|
|
// not anywhere else in the payload.
|
|
if strings.Contains(recorder.Body.String(), "sk-super-secret") {
|
|
t.Fatalf("configure response leaked the secret value: %s", recorder.Body.String())
|
|
}
|
|
var configured struct {
|
|
Config map[string]any `json:"config"`
|
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &configured); err != nil {
|
|
t.Fatalf("decode configured installation: %v", err)
|
|
}
|
|
if configured.Config["repo"] != "multica-ai/multica" {
|
|
t.Fatalf("plain config value was not stored: %v", configured.Config)
|
|
}
|
|
if _, present := configured.Config["token"]; present {
|
|
t.Fatalf("secret value was stored in config: %v", configured.Config)
|
|
}
|
|
if len(configured.ConfiguredSecrets) != 1 || configured.ConfiguredSecrets[0] != "token" {
|
|
t.Fatalf("configured secrets = %v, want [token]", configured.ConfiguredSecrets)
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.ListPlugins(recorder, pluginHandlerRequest(http.MethodGet, "/plugins", nil, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "sk-super-secret") {
|
|
t.Fatalf("list leaked the secret or failed: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
unknown, _ := json.Marshal(map[string]any{"values": map[string]any{"nope": "x"}})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.ConfigurePlugin(recorder, pluginHandlerRequest(http.MethodPut, "/plugins/config", unknown, params))
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("unknown config field status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.DisablePlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/disable", nil, params))
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"enabled":false`) {
|
|
t.Fatalf("disable status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.UninstallPlugin(recorder, pluginHandlerRequest(http.MethodDelete, "/plugins", nil, params))
|
|
if recorder.Code != http.StatusNoContent {
|
|
t.Fatalf("uninstall status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// Uninstall must take the plugin's own state with it, or a reinstall would
|
|
// silently inherit rows nobody can audit.
|
|
var remaining int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT (SELECT COUNT(*) FROM plugin_secret WHERE installation_id = $1)
|
|
+ (SELECT COUNT(*) FROM plugin_storage WHERE installation_id = $1)`,
|
|
installed.ID).Scan(&remaining); err != nil {
|
|
t.Fatalf("count leftover plugin rows: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Fatalf("uninstall left %d plugin-owned rows behind", remaining)
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallRejectsMalformedManifest(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
source := withLocalPluginSource(t, `{"manifest_version":1,"key":"com.example.hello","surprise":true}`)
|
|
|
|
body, _ := json.Marshal(map[string]any{"source_url": source, "granted_scopes": []string{}})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("malformed manifest status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "manifest") {
|
|
t.Fatalf("malformed manifest error is not actionable: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallRejectsUnshippedCapabilities(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
source := withLocalPluginSource(t, handlerTestManifest)
|
|
// A declared-but-unshipped contribution must fail the install outright: a
|
|
// silently dropped surface would look installed and never render.
|
|
testHandler.PluginService.Host = plugincontract.HostCapabilities()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"source_url": source,
|
|
"granted_scopes": []string{"issues:read", "comments:write", "storage:user"},
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("unshipped capability status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "issue_panel") {
|
|
t.Fatalf("error does not name the missing capability: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallationFromAnotherWorkspaceIsNotFound(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.EnablePlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/enable", nil, map[string]string{
|
|
"id": testWorkspaceID,
|
|
"installationId": "11111111-1111-1111-1111-111111111111",
|
|
}))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginUpgradePrunesSecretsTheNewManifestDropped(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
root := t.TempDir()
|
|
source := withLocalPluginSourceIn(t, root, handlerTestManifest)
|
|
|
|
install, _ := json.Marshal(map[string]any{
|
|
"source_url": source,
|
|
"granted_scopes": []string{"issues:read", "comments:write", "storage:user"},
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", install, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("install status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var installed struct {
|
|
ID string `json:"id"`
|
|
}
|
|
json.Unmarshal(recorder.Body.Bytes(), &installed)
|
|
|
|
params := map[string]string{"id": testWorkspaceID, "installationId": installed.ID}
|
|
configure, _ := json.Marshal(map[string]any{"values": map[string]any{"token": "sk-old-secret"}})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.ConfigurePlugin(recorder, pluginHandlerRequest(http.MethodPut, "/plugins/config", configure, params))
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured_secrets":["token"]`) {
|
|
t.Fatalf("configure status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// v2 drops the `token` secret field entirely. Its ciphertext must go with
|
|
// it: nothing can reach a secret whose field no longer exists, and leaving
|
|
// it means an uninstall is the only thing that ever removes it.
|
|
upgraded := strings.Replace(handlerTestManifest, `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
|
|
upgraded = strings.Replace(upgraded, `,
|
|
"token": { "type": "secret", "label": "Token" }`, "", 1)
|
|
writeLocalPluginManifest(t, root, upgraded)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", install, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("upgrade status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"configured_secrets":[]`) {
|
|
t.Fatalf("upgrade left an unreachable secret: %s", recorder.Body.String())
|
|
}
|
|
|
|
var remaining int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM plugin_secret WHERE installation_id = $1`, installed.ID).Scan(&remaining); err != nil {
|
|
t.Fatalf("count secrets: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Fatalf("upgrade left %d secret rows behind", remaining)
|
|
}
|
|
}
|
|
|
|
// TestPluginRoutesRequireWorkspaceAdmin pins the gate that actually protects
|
|
// these endpoints. Every other handler test calls the handler directly, which
|
|
// bypasses middleware entirely — so without this, "install/configure/uninstall
|
|
// are admin-only" rests on where the routes sit in router.go and nothing would
|
|
// fail if one moved into the member group.
|
|
func TestPluginRoutesRequireWorkspaceAdmin(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
memberID := createTestUserAndMember(t, "member")
|
|
|
|
router := chi.NewRouter()
|
|
router.Route("/api/workspaces/{id}", func(r chi.Router) {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(testHandler.Queries, "id", "owner", "admin"))
|
|
r.Post("/plugins/preview", testHandler.PreviewPlugin)
|
|
r.Post("/plugins", testHandler.InstallPlugin)
|
|
r.Put("/plugins/{installationId}/config", testHandler.ConfigurePlugin)
|
|
r.Post("/plugins/{installationId}/enable", testHandler.EnablePlugin)
|
|
r.Post("/plugins/{installationId}/disable", testHandler.DisablePlugin)
|
|
r.Delete("/plugins/{installationId}", testHandler.UninstallPlugin)
|
|
})
|
|
// Listing is member-visible on purpose: a member should be able to see
|
|
// what is mounted in their workspace and which scopes it holds.
|
|
r.Get("/plugins", testHandler.ListPlugins)
|
|
})
|
|
|
|
base := "/api/workspaces/" + testWorkspaceID
|
|
adminOnly := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodPost, base + "/plugins/preview"},
|
|
{http.MethodPost, base + "/plugins"},
|
|
{http.MethodPut, base + "/plugins/11111111-1111-1111-1111-111111111111/config"},
|
|
{http.MethodPost, base + "/plugins/11111111-1111-1111-1111-111111111111/enable"},
|
|
{http.MethodPost, base + "/plugins/11111111-1111-1111-1111-111111111111/disable"},
|
|
{http.MethodDelete, base + "/plugins/11111111-1111-1111-1111-111111111111"},
|
|
}
|
|
for _, route := range adminOnly {
|
|
t.Run(route.method+" "+route.path, func(t *testing.T) {
|
|
request := newRequest(route.method, route.path, map[string]any{})
|
|
request.Header.Set("X-User-ID", memberID)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("plain member reached an admin route: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
request := newRequest(http.MethodGet, base+"/plugins", nil)
|
|
request.Header.Set("X-User-ID", memberID)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("plain member could not list Plugins: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|