mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350) The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed surface asked the host, and the host acted on the signed-in user's session, so nothing left our infrastructure on a plugin's say-so. A hook leaves, and every new check exists because of that. Destination: the target host must be inside the granted `net:` set, resolve to a public address, and be re-checked at dial. Body: HMAC-SHA256 over timestamp.body, so a receiver can tell our call from anyone else's and a captured one expires. Identity follows the trigger, not the plugin. A ui/manual hook runs because a person pressed something, so its writes stay theirs; an event hook has nobody behind it and writes as the installation — hence the fourth author_type. Two credentials, moving opposite ways, which is what decides how each is stored. The install token goes plugin -> host and is only ever verified, so it is hashed. The signing secret goes host -> plugin and must be reproduced to sign with, so it is derived from the deployment key and stored nowhere. Event hooks never block the host: Bus.Publish runs listeners inline on the publishing request's goroutine, so the dispatcher hands off to a worker pool immediately. Three attempts, then a circuit breaker, then silence. Co-authored-by: multica-agent <[email protected]> * feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350) The host owns the manual menu entry rather than letting a surface draw one, because the trigger decides identity: a manual call acts as the person who picked it, so the entry has to live where the host can prove somebody did. The ui trigger goes through the bridge for the same reason a surface cannot fetch its own backend directly — routing through the host is what makes the call signed, rate limited, bounded to the granted net: domains and recorded. buildActorNameResolver gains a plugin branch. Without it an event-written comment rendered as "System", which is the one attribution that is actively misleading: it reads as the platform having said something. examples/plugins/triage-notify carries the half that does NOT run in Multica — a reference handler showing the four things a receiver must do, including the replay guard the host cannot do for it. TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example drift: nothing checked that shipped documentation still parses, or that it declares only what this build can run. TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/ event on turned it red with a message about the flip rather than about the gate. It now derives its expectation from HostCapabilities and survives a flip while still failing if the gate stops checking a category. Co-authored-by: multica-agent <[email protected]> * feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350) TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the modal surface on without a renderer — an install that succeeds and then never appears, which is the exact failure that guard exists to prevent. Built the mount point rather than weakening the guard, and rewrote it to name the host code behind each line so the next flip has to change both together. plugin_invocation was missing from workspace teardown and from uninstall. TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second would have left rows naming an installation that no longer exists. Deleted by workspace_id rather than through installation ids, so a record whose plugin was already uninstalled does not outlive the workspace it describes. A modal opens because a person picked it from the issue menu, never on the plugin's own initiative — same sandbox as a panel, differing only in where it appears. Co-authored-by: multica-agent <[email protected]> * test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350) Writing the never-blocks test found a real bug rather than confirming one. Bus.Publish recovers panics in its listeners so one bad handler cannot kill the request that published; moving delivery onto a worker goroutine stepped outside that protection, where a panic is not a failed hook but a dead process. runGuarded restores the guarantee the hand-off gave up. The bridge now takes an EventSink rather than the concrete dispatcher, so the vocabulary mapping — which internal event becomes which published one — is asserted directly instead of through a live endpoint. That mapping is a published contract; the internal names are not. The backpressure test stops the workers rather than racing them, so it reproduces a saturated pool without depending on timing, and asserts the exact overflow instead of "something was dropped". Co-authored-by: multica-agent <[email protected]> * fix(plugins): two hook-contract bugs a live run found (MUL-6350) Both were invisible to the tests because the tests made one call each, and a real handler does not. The callback token was single-use. The reference handler reads the issue, decides, then posts a comment — and the second call died on a spent token. Read-then-write is the floor for a handler that does anything with what it read, so a single-use grant made the callback unusable. The trade that decides it is not one call versus several. It is this token versus the installation's standing token: a handler that cannot finish with the callback gets written against the install token instead, which never expires and is not scoped to an invocation. A control that pushes authors toward the stronger credential is worse than the looser one they will actually use. The grant is now invocation-scoped — minutes, this installation's scopes, the actor fixed at dispatch — and revoked as soon as the call it was issued for returns. The hook body also omitted the issue. The host resolves and permission-checks it, then said nothing, so a handler had to read a client-supplied field — unvalidated for ui/manual, and absent entirely for event, where there is no client. issue_id now travels in the body. Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing against localhost needs its certificate trusted by something. A named CA file rather than InsecureSkipVerify, which survives into production behind a config flag nobody remembers and cannot be spotted by reading the code. Co-authored-by: multica-agent <[email protected]> * fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350) Addresses Fabel 5's review. The first item is the one that mattered. The callback grant's issue narrowing existed only in two doc comments and the PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual token was worth every issue in the workspace its actor could see, for the whole five minutes it lived, on read and write alike. Now carried into the caller and checked in pluginIssueForUser, after resolution so an identifier and a uuid for the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms the id exists. DeleteExpiredPluginInvocations was generated and never called, while the table comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's lifecycle — same concern, bounded resources for something a third party drives. Event subscriptions bypassed the read scopes. issue.* pushes the description and comment.created pushes the body — the same content the Action API refuses without issues:read / comments:read. Subscribing was a way to receive what reading was not granted. Enforced at manifest validation so the consent screen shows the read scope a subscription implies. Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE. NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing and stops nothing. Plus the half-built breaker UI (a plugin could be enabled and silently delivering nothing), redirect refusal on the dev hook client (a 302 would have replayed the signed body and the callback token onward), and omitting callback_url rather than sending a relative one. Co-authored-by: multica-agent <[email protected]> * fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350) CI panicked in cmd/server: the sweeper I added last round fired immediately on its own goroutine, and that test builds a router over a Queries whose pool was never opened. pgxpool dereferenced the nil pool and the process died. Two things were wrong, and only one of them is the obvious one. The sweep no longer runs at construction. It waited for nothing and gained one hour of retention on a cold start against a 7-day TTL; the cost was a goroutine reaching for the database before the process is known to have one. The nil check was also the wrong instrument. sqlc's Queries wraps an executor, so a non-nil Queries over an unopened pool passes every comparison available at that layer and only fails inside pgxpool — no amount of nil-checking here can see it. The guard is a recover, for the same reason runGuarded exists: on a bare goroutine a panic is not a failed sweep, it is a dead server. I had already fixed that class once for delivery and then reintroduced it in the sweeper. The regression test builds the exact shape CI hit — a dispatcher over db.New(nil) — and fails if either the construction-time sweep or the recover comes back. cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool. Co-authored-by: multica-agent <[email protected]> * fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350) Answering "is this still behind the flag" turned up the one place it was not. Every other plugin surface is refused by a handler that reads the flag off a request. An event hook has no request: the dispatcher is subscribed to the bus at startup and delivers on a worker. Nothing in it consulted the flag, so turning plugins off hid the UI and refused the Action API while the OUTBOUND calls kept going — fail-open on the one path that reaches a third party. It only bites after a plugin has been installed, because installation is flag-gated, so a deployment that never enabled plugins was never exposed. A workspace that enabled it, installed something, then turned it off was. The check reads the flag per delivery rather than once at subscription, so the flip takes effect immediately instead of at the next restart. It also removes the flag-off cost: every dispatched event used to run a ListWorkspacePluginInstallations query to discover there was nothing to call. Nil flags read as disabled — a deployment that never wired the flag service must not get outbound hooks by omission. The test lives in internal/handler because it needs a real installed plugin to mean anything: with the flag on the endpoint is called, with it off nothing leaves while the same installation stays enabled and still declares the hook. Removing the check makes it fail on the second half. Co-authored-by: multica-agent <[email protected]> * perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350) Found while answering whether merging can affect existing users, and the answer was "in one measurable way it can". The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON marshal plus unmarshal of the full event payload — for issue.created that is the whole issue body, description included — ran inline on the goroutine of whatever request published. Seven event types, every workspace, and the feature flag was checked far downstream on the worker, which then threw the result away. A deployment with plugins switched off paid for all of it. The listener now hands the payload over unexamined and the id is extracted on the worker, past the flag check. It also fits the rule this file already claimed: the publishing request does the least it possibly can. Not moved: the status_changed lookup, which is a map read, not a parse. TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and fails if any happen — restoring the old argument makes it fail with the count. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
533 lines
23 KiB
Go
533 lines
23 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))
|
|
}
|
|
|
|
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)
|
|
// 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.
|
|
source := 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{
|
|
"source_url": source,
|
|
"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)
|
|
source := withLocalPluginSource(t, hookOnlyTestManifest)
|
|
testHandler.PluginService.Host = plugincontract.HostCapabilities()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"source_url": source,
|
|
"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.
|
|
source := withLocalPluginSource(t, handlerTestManifest)
|
|
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.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()
|
|
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())
|
|
}
|
|
}
|