mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
644 lines
28 KiB
Go
644 lines
28 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" }]
|
|
}
|
|
}`
|
|
|
|
// hookOnlyTestManifest declares a contribution kind the host does not ship yet.
|
|
// Kept separate from handlerTestManifest so flipping a surface on never silently
|
|
// changes what the capability-gate test is asserting.
|
|
const hookOnlyTestManifest = `{
|
|
"manifest_version": 1,
|
|
"key": "com.example.hooked",
|
|
"name": "Hooked",
|
|
"version": "1.0.0",
|
|
"author": { "name": "example" },
|
|
"scopes": ["issues:read", "net:example.com"],
|
|
"contributes": {
|
|
"hooks": [{
|
|
"key": "summarize",
|
|
"name": "Summarize",
|
|
"description": "Compress the discussion.",
|
|
"triggers": ["ui"],
|
|
"transport": { "type": "http", "url": "https://example.com/hooks/summarize" }
|
|
}]
|
|
}
|
|
}`
|
|
|
|
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))
|
|
}
|
|
|
|
// writeLocalPluginManifest writes the manifest plus a stub for every file it
|
|
// declares.
|
|
//
|
|
// Publishing validates that the whole bundle is present, which is the point of
|
|
// the artifact model: a surface whose entry does not exist is refused at publish
|
|
// instead of failing in a reader's browser. A file a test already wrote is left
|
|
// alone, so a suite that supplies its own SKILL.md keeps it.
|
|
func writeLocalPluginManifest(t *testing.T, root, manifest string) {
|
|
t.Helper()
|
|
dir := filepath.Join(root, "hello")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("create local plugin dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, plugincontract.ManifestFilename), []byte(manifest), 0o644); err != nil {
|
|
t.Fatalf("write local manifest: %v", err)
|
|
}
|
|
// A manifest that does not parse is itself a fixture — publishing has to be
|
|
// what rejects it, so there is nothing to stub out.
|
|
parsed, _, err := plugincontract.ParseManifest([]byte(manifest))
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, surface := range parsed.Contributes.Surfaces {
|
|
writeLocalPluginFile(t, dir, surface.Entry, "// stub surface\n")
|
|
}
|
|
for _, resource := range parsed.Contributes.Resources {
|
|
writeLocalPluginFile(t, dir, resource.Entry, "---\nname: "+resource.Key+"\ndescription: Stub resource.\n---\n\nStub.\n")
|
|
}
|
|
if parsed.Icon != "" {
|
|
writeLocalPluginFile(t, dir, parsed.Icon, "icon")
|
|
}
|
|
}
|
|
|
|
func writeLocalPluginFile(t *testing.T, dir, entry, content string) {
|
|
t.Helper()
|
|
path := filepath.Join(dir, filepath.FromSlash(entry))
|
|
if _, err := os.Stat(path); err == nil {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("create plugin file dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write plugin file %s: %v", entry, err)
|
|
}
|
|
}
|
|
|
|
// withLocalPluginSource points the service at a temp MULTICA_PLUGIN_DIR,
|
|
// enables every capability, publishes the plugin, and returns the published
|
|
// version id — which is what an install names now.
|
|
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 publish a second version from it.
|
|
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 publishLocalPlugin(t, "hello")
|
|
}
|
|
|
|
// publishLocalPlugin publishes a directory under MULTICA_PLUGIN_DIR and returns
|
|
// the id of the version it created. Re-publishing an unchanged version string is
|
|
// what a development loop does, so the service gives those a `+dev.N` suffix
|
|
// rather than a conflict — which is why this always returns a NEW version.
|
|
func publishLocalPlugin(t *testing.T, name string) string {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]string{"name": name})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.PublishLocalPluginPackage(recorder,
|
|
pluginHandlerRequest(http.MethodPost, "/plugins/packages/local", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("publish %s: status=%d body=%s", name, recorder.Code, recorder.Body.String())
|
|
}
|
|
var published service.PluginPackageSummary
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &published); err != nil {
|
|
t.Fatalf("decode published package: %v", err)
|
|
}
|
|
if len(published.Versions) == 0 {
|
|
t.Fatalf("publish %s returned no versions", name)
|
|
}
|
|
// Newest first.
|
|
return published.Versions[0].ID
|
|
}
|
|
|
|
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)
|
|
testPool.Exec(ctx, `DELETE FROM plugin_package_file WHERE version_id IN (SELECT id FROM plugin_package_version WHERE workspace_id = $1)`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM plugin_package_version WHERE workspace_id = $1`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM plugin_package 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)
|
|
versionID := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
body, _ := json.Marshal(map[string]string{"version_id": versionID})
|
|
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)
|
|
versionID := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
partial, _ := json.Marshal(map[string]any{"version_id": versionID, "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{
|
|
"version_id": versionID,
|
|
"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)
|
|
versionID := withLocalPluginSource(t, handlerTestManifest)
|
|
|
|
install, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// A malformed manifest is now refused by PUBLISHING, not by installing. That is
|
|
// the move this whole change is: the artifact is parsed once, when the author
|
|
// hands it over, so an administrator can never be shown a consent screen for
|
|
// something that was never going to load.
|
|
func TestPluginPublishRejectsMalformedManifest(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
root := t.TempDir()
|
|
writeLocalPluginManifest(t, root, `{"manifest_version":1,"key":"com.example.hello","surprise":true}`)
|
|
previousDir := testHandler.PluginService.LocalDir
|
|
testHandler.PluginService.LocalDir = root
|
|
t.Cleanup(func() { testHandler.PluginService.LocalDir = previousDir })
|
|
|
|
body, _ := json.Marshal(map[string]string{"name": "hello"})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.PublishLocalPluginPackage(recorder,
|
|
pluginHandlerRequest(http.MethodPost, "/plugins/packages/local", 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())
|
|
}
|
|
}
|
|
|
|
// The other half of publish-time validation: a manifest that parses but names a
|
|
// file the bundle does not carry. Under URL hosting this failed months later in
|
|
// a reader's browser, with nothing in the product that could have caught it.
|
|
func TestPluginPublishRejectsMissingSurfaceEntry(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
root := t.TempDir()
|
|
// Written directly rather than through writeLocalPluginManifest, which
|
|
// stubs out every declared file — the missing entry IS the fixture.
|
|
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(handlerTestManifest), 0o644); err != nil {
|
|
t.Fatalf("write local manifest: %v", err)
|
|
}
|
|
previousDir := testHandler.PluginService.LocalDir
|
|
testHandler.PluginService.LocalDir = root
|
|
t.Cleanup(func() { testHandler.PluginService.LocalDir = previousDir })
|
|
|
|
body, _ := json.Marshal(map[string]string{"name": "hello"})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.PublishLocalPluginPackage(recorder,
|
|
pluginHandlerRequest(http.MethodPost, "/plugins/packages/local", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing surface entry status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "ui/main.js") {
|
|
t.Fatalf("error does not name the missing file: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallRejectsUnshippedCapabilities(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
// Tests the WIRING — that install consults the capability gate and refuses
|
|
// what the host cannot run — against an explicitly narrowed host set rather
|
|
// than against whatever HostCapabilities happens to contain today.
|
|
//
|
|
// The earlier version picked a contribution kind that was unshipped at the
|
|
// time, so shipping it turned this red with a message about the flip instead
|
|
// of about the gate. It also had a shelf life: once every kind ships there
|
|
// is nothing left to write such a fixture against. Narrowing the host here
|
|
// keeps the assertion true for good.
|
|
versionID := withLocalPluginSource(t, hookOnlyTestManifest)
|
|
testHandler.PluginService.Host = plugincontract.Capabilities{
|
|
SurfaceTypes: map[string]bool{plugincontract.SurfaceIssuePanel: true},
|
|
HookTriggers: map[string]bool{},
|
|
HookTransport: map[string]bool{},
|
|
ResourceTypes: map[string]bool{},
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"granted_scopes": []string{"issues:read", "net:example.com"},
|
|
})
|
|
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())
|
|
}
|
|
// A declared-but-unrunnable contribution must fail the install outright and
|
|
// name what is missing: silently dropping it would look installed and never
|
|
// fire.
|
|
if !strings.Contains(recorder.Body.String(), "hook trigger") {
|
|
t.Fatalf("error does not name the missing capability: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
// The gate must also let through what the host DOES ship, read from the real
|
|
// set. Together with the test above this pins both directions: nothing
|
|
// unrunnable installs, and nothing runnable is refused.
|
|
func TestPluginInstallAcceptsEveryCapabilityThisHostShips(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
versionID := withLocalPluginSource(t, hookOnlyTestManifest)
|
|
testHandler.PluginService.Host = plugincontract.HostCapabilities()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"granted_scopes": []string{"issues:read", "net:example.com"},
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
|
|
// hookOnlyTestManifest declares ui + http, both shipped by the hook engine.
|
|
// If this starts failing, the gate is refusing something the host can run.
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("a manifest declaring only shipped capabilities was refused: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallAcceptsAShippedSurface(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
// The other half of the gate: a contribution the host DOES ship must install
|
|
// against the real HostCapabilities set, not only against a test set that
|
|
// enables everything.
|
|
versionID := withLocalPluginSource(t, handlerTestManifest)
|
|
testHandler.PluginService.Host = plugincontract.HostCapabilities()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"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.StatusCreated {
|
|
t.Fatalf("shipped surface was refused: status=%d body=%s", recorder.Code, 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()
|
|
versionID := withLocalPluginSourceIn(t, root, handlerTestManifest)
|
|
|
|
install, _ := json.Marshal(map[string]any{
|
|
"version_id": versionID,
|
|
"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)
|
|
// Upgrading is installing a DIFFERENT version. Re-posting the same version
|
|
// id would be a no-op by design, which is the guarantee under test elsewhere.
|
|
upgradedVersionID := publishLocalPlugin(t, "hello")
|
|
|
|
upgrade, _ := json.Marshal(map[string]any{
|
|
"version_id": upgradedVersionID,
|
|
"granted_scopes": []string{"issues:read", "comments:write", "storage:user"},
|
|
})
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", upgrade, 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())
|
|
}
|
|
}
|