mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01: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]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * 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 * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
345 lines
14 KiB
Go
345 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/pkg/plugincontract"
|
|
)
|
|
|
|
// Publishing, and the guarantee it exists for.
|
|
//
|
|
// The old model froze the manifest at install and loaded the surface script from
|
|
// the author's server on every panel open, so "the administrator approved this"
|
|
// and "this is what runs" were two different statements. These tests pin the
|
|
// replacement: an installation names one immutable version, and publishing
|
|
// another one changes nothing until an administrator upgrades.
|
|
|
|
const packageTestManifest = `{
|
|
"manifest_version": 1,
|
|
"key": "com.example.published",
|
|
"name": "Published Panel",
|
|
"version": "%VERSION%",
|
|
"author": { "name": "example" },
|
|
"scopes": ["issues:read"],
|
|
"contributes": {
|
|
"surfaces": [{ "key": "hello", "type": "issue_panel", "name": "Hello", "entry": "ui/main.js" }]
|
|
}
|
|
}`
|
|
|
|
func packageManifest(version string) string {
|
|
return strings.Replace(packageTestManifest, "%VERSION%", version, 1)
|
|
}
|
|
|
|
func pluginBundleZip(t *testing.T, manifest, script string) []byte {
|
|
t.Helper()
|
|
var buffer bytes.Buffer
|
|
writer := zip.NewWriter(&buffer)
|
|
for name, content := range map[string]string{
|
|
plugincontract.ManifestFilename: manifest,
|
|
"ui/main.js": script,
|
|
} {
|
|
entry, err := writer.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create zip entry %s: %v", name, err)
|
|
}
|
|
if _, err := entry.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write zip entry %s: %v", name, err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buffer.Bytes()
|
|
}
|
|
|
|
func uploadPluginBundle(t *testing.T, archive []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
form := multipart.NewWriter(&body)
|
|
part, err := form.CreateFormFile("bundle", "plugin.zip")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(archive); err != nil {
|
|
t.Fatalf("write form file: %v", err)
|
|
}
|
|
if err := form.Close(); err != nil {
|
|
t.Fatalf("close form: %v", err)
|
|
}
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/plugins/packages", &body)
|
|
request.Header.Set("X-User-ID", testUserID)
|
|
request.Header.Set("Content-Type", form.FormDataContentType())
|
|
routeContext := chi.NewRouteContext()
|
|
routeContext.URLParams.Add("id", testWorkspaceID)
|
|
request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.PublishPluginPackage(recorder, request)
|
|
return recorder
|
|
}
|
|
|
|
func publishUploadedBundle(t *testing.T, manifest, script string) service.PluginPackageSummary {
|
|
t.Helper()
|
|
recorder := uploadPluginBundle(t, pluginBundleZip(t, manifest, script))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("publish: status=%d body=%s", 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)
|
|
}
|
|
return published
|
|
}
|
|
|
|
func installPublishedVersion(t *testing.T, versionID string) string {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{"version_id": versionID, "granted_scopes": []string{"issues:read"}})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, 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"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
|
|
t.Fatalf("decode installation: %v", err)
|
|
}
|
|
return installed.ID
|
|
}
|
|
|
|
func surfaceScript(t *testing.T, installationID, surfaceKey string) (*httptest.ResponseRecorder, service.PluginSurfaceScript) {
|
|
t.Helper()
|
|
previousOrigin := testHandler.cfg.PluginSurfaceOrigin
|
|
previousTokens := testHandler.PluginSurfaceTokens
|
|
testHandler.cfg.PluginSurfaceOrigin = "https://plugin-content.example.test"
|
|
testHandler.PluginSurfaceTokens, _ = NewPluginSurfaceTokenBox(bytes.Repeat([]byte{7}, 32))
|
|
t.Cleanup(func() {
|
|
testHandler.cfg.PluginSurfaceOrigin = previousOrigin
|
|
testHandler.PluginSurfaceTokens = previousTokens
|
|
})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.GetPluginSurfaceLaunch(recorder, pluginHandlerRequest(http.MethodGet, "/launch", nil, map[string]string{
|
|
"id": testWorkspaceID,
|
|
"installationId": installationID,
|
|
"surfaceKey": surfaceKey,
|
|
}))
|
|
if recorder.Code != http.StatusOK {
|
|
return recorder, service.PluginSurfaceScript{}
|
|
}
|
|
var launch pluginSurfaceLaunch
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &launch); err != nil {
|
|
t.Fatalf("decode surface launch: %v", err)
|
|
}
|
|
token := launch.URL[strings.LastIndex(launch.URL, "/")+1:]
|
|
documentRecorder := httptest.NewRecorder()
|
|
documentRequest := pluginHandlerRequest(http.MethodGet, "/plugin-surfaces/"+token, nil, map[string]string{"token": token})
|
|
documentRequest.Host = "plugin-content.example.test"
|
|
testHandler.ServePluginSurface(documentRecorder, documentRequest)
|
|
if documentRecorder.Code != http.StatusOK {
|
|
t.Fatalf("serve hosted surface: status=%d body=%s", documentRecorder.Code, documentRecorder.Body.String())
|
|
}
|
|
encoded := regexp.MustCompile(`id="multica-surface-code">([^<]+)</script>`).FindStringSubmatch(documentRecorder.Body.String())
|
|
if len(encoded) != 2 {
|
|
t.Fatal("hosted surface did not contain stored code")
|
|
}
|
|
code, err := base64.StdEncoding.DecodeString(encoded[1])
|
|
if err != nil {
|
|
t.Fatalf("decode hosted surface code: %v", err)
|
|
}
|
|
return recorder, service.PluginSurfaceScript{Code: string(code), Version: launch.Version, Digest: launch.Digest}
|
|
}
|
|
|
|
// An author with no server of their own publishes a panel plugin by upload, an
|
|
// administrator installs it, and the panel's code comes back from us. That is
|
|
// the acceptance criterion for the whole change.
|
|
func TestUploadedBundleInstallsAndServesItsOwnCode(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
withHostCapabilities(t)
|
|
|
|
published := publishUploadedBundle(t, packageManifest("1.0.0"), "console.log('v1');\n")
|
|
if len(published.Versions) != 1 || published.Versions[0].Version != "1.0.0" {
|
|
t.Fatalf("unexpected published versions: %+v", published.Versions)
|
|
}
|
|
if published.Versions[0].Digest == "" || published.Versions[0].SizeBytes == 0 {
|
|
t.Fatalf("a published version must report what was stored: %+v", published.Versions[0])
|
|
}
|
|
|
|
installationID := installPublishedVersion(t, published.Versions[0].ID)
|
|
recorder, script := surfaceScript(t, installationID, "hello")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("surface script: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(script.Code, "console.log('v1')") {
|
|
t.Fatalf("the served code is not what was uploaded: %q", script.Code)
|
|
}
|
|
if script.Version != "1.0.0" {
|
|
t.Fatalf("script version = %q, want the installed version", script.Version)
|
|
}
|
|
}
|
|
|
|
// The invariant the RFC called non-negotiable: publishing again must not change
|
|
// what an installed workspace runs.
|
|
func TestPublishingANewVersionDoesNotChangeAnInstalledWorkspace(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
withHostCapabilities(t)
|
|
|
|
first := publishUploadedBundle(t, packageManifest("1.0.0"), "console.log('v1');\n")
|
|
installationID := installPublishedVersion(t, first.Versions[0].ID)
|
|
|
|
second := publishUploadedBundle(t, packageManifest("2.0.0"), "console.log('v2');\n")
|
|
if len(second.Versions) != 2 {
|
|
t.Fatalf("the second publish should add a version, not replace one: %+v", second.Versions)
|
|
}
|
|
|
|
_, script := surfaceScript(t, installationID, "hello")
|
|
if strings.Contains(script.Code, "v2") {
|
|
t.Fatalf("a publish changed the code an installed workspace runs: %q", script.Code)
|
|
}
|
|
if script.Version != "1.0.0" {
|
|
t.Fatalf("installed version = %q, want 1.0.0 until an administrator upgrades", script.Version)
|
|
}
|
|
|
|
// The settings page has to be able to say which one is running, or "upgrade
|
|
// available" is not something an administrator can act on.
|
|
installedFlags := map[string]bool{}
|
|
for _, version := range second.Versions {
|
|
installedFlags[version.Version] = version.Installed
|
|
}
|
|
if !installedFlags["1.0.0"] || installedFlags["2.0.0"] {
|
|
t.Fatalf("installed markers are wrong: %+v", second.Versions)
|
|
}
|
|
|
|
// And upgrading is what moves it.
|
|
upgradeVersionID := ""
|
|
for _, version := range second.Versions {
|
|
if version.Version == "2.0.0" {
|
|
upgradeVersionID = version.ID
|
|
}
|
|
}
|
|
if got := installPublishedVersion(t, upgradeVersionID); got != installationID {
|
|
t.Fatalf("upgrade created a second installation: %s != %s", got, installationID)
|
|
}
|
|
if _, upgraded := surfaceScript(t, installationID, "hello"); !strings.Contains(upgraded.Code, "v2") {
|
|
t.Fatalf("after upgrading, the code is still %q", upgraded.Code)
|
|
}
|
|
}
|
|
|
|
// Republishing a version is refused rather than accepted as an overwrite. The
|
|
// unique index is what makes immutability a rule instead of a convention.
|
|
func TestPublishRefusesToOverwriteAnExistingVersion(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
withHostCapabilities(t)
|
|
|
|
publishUploadedBundle(t, packageManifest("1.0.0"), "console.log('v1');\n")
|
|
recorder := uploadPluginBundle(t, pluginBundleZip(t, packageManifest("1.0.0"), "console.log('tampered');\n"))
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("republish status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "immutable") {
|
|
t.Fatalf("the conflict does not explain itself: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDeletePublishedPackageRefusesWhileInstalled(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
withHostCapabilities(t)
|
|
|
|
published := publishUploadedBundle(t, packageManifest("1.0.0"), "console.log('v1');\n")
|
|
installationID := installPublishedVersion(t, published.Versions[0].ID)
|
|
|
|
params := map[string]string{"id": testWorkspaceID, "packageId": published.ID}
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.DeletePluginPackage(recorder, pluginHandlerRequest(http.MethodDelete, "/plugins/packages", nil, params))
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("delete while installed status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
uninstall := httptest.NewRecorder()
|
|
testHandler.UninstallPlugin(uninstall, pluginHandlerRequest(http.MethodDelete, "/plugins", nil, map[string]string{
|
|
"id": testWorkspaceID, "installationId": installationID,
|
|
}))
|
|
if uninstall.Code != http.StatusNoContent {
|
|
t.Fatalf("uninstall status=%d body=%s", uninstall.Code, uninstall.Body.String())
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.DeletePluginPackage(recorder, pluginHandlerRequest(http.MethodDelete, "/plugins/packages", nil, params))
|
|
if recorder.Code != http.StatusNoContent {
|
|
t.Fatalf("delete after uninstall status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// The stored bundle goes with it; a package row with orphaned files behind
|
|
// it is storage nothing can reach.
|
|
var remaining int
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM plugin_package_file WHERE version_id IN (
|
|
SELECT id FROM plugin_package_version WHERE workspace_id = $1)`, testWorkspaceID).Scan(&remaining); err != nil {
|
|
t.Fatalf("count package files: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Fatalf("deleting the package left %d files behind", remaining)
|
|
}
|
|
}
|
|
|
|
func TestSurfaceScriptRefusesWhatWasNeverInstalled(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
withHostCapabilities(t)
|
|
|
|
published := publishUploadedBundle(t, packageManifest("1.0.0"), "console.log('v1');\n")
|
|
installationID := installPublishedVersion(t, published.Versions[0].ID)
|
|
|
|
// A surface key the installed manifest does not declare. The manifest
|
|
// consulted is the installation's own snapshot, so this cannot be widened by
|
|
// publishing a version that declares more.
|
|
if recorder, _ := surfaceScript(t, installationID, "nope"); recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown surface key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
disable := httptest.NewRecorder()
|
|
testHandler.DisablePlugin(disable, pluginHandlerRequest(http.MethodPost, "/plugins/disable", nil, map[string]string{
|
|
"id": testWorkspaceID, "installationId": installationID,
|
|
}))
|
|
if disable.Code != http.StatusOK {
|
|
t.Fatalf("disable status=%d body=%s", disable.Code, disable.Body.String())
|
|
}
|
|
// Disabling has to stop the code from being served, or "disabled" is a UI
|
|
// state rather than a decision.
|
|
if recorder, _ := surfaceScript(t, installationID, "hello"); recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("disabled installation served its code: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
// withHostCapabilities enables every contribution kind for the duration of a
|
|
// test, so these exercise publishing rather than the staged-rollout gate.
|
|
func withHostCapabilities(t *testing.T) {
|
|
t.Helper()
|
|
previous := testHandler.PluginService.Host
|
|
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},
|
|
}
|
|
t.Cleanup(func() { testHandler.PluginService.Host = previous })
|
|
}
|