Files
multica/server/internal/handler/plugin_test.go
T
686957e743 MUL-7271: test(server): cut slow Go test time further (follow-up to #8295) (#8318)
* test(server): reduce slow unit test waits

Co-authored-by: multica-agent <[email protected]>

* test(server): cut slow Go unit test time further

Follow-up to the first pass on MUL-7271. Test-only changes plus a few
package-private seams whose production values are unchanged.

- Re-executed race-built test binaries no longer pay the race runtime's 1s
  atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only).
- pkg/agent: event-driven or shorter test-only bounds for handshake, drain
  and idle graces; redundant timeout variants merged.
- daemon/execenv: git fixtures copied from a template repo built once; tests
  that re-ran the same worktree lifecycle folded into their supersets;
  ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY.
- handler/service: cap and paging tests shrink their caps instead of seeding
  thousands of rows; lock waits probe pg_locks instead of sleeping; one
  shared pgxpool per service test binary; cheaper cron and RSA fixtures.
- wecom: the phishing-link sweep reuses one parser and derives CRLF from LF
  with a sampled cross-check; fake sockets acknowledge instead of waiting out
  the ack timeout.
- migrate: the migration corpus is parsed once; isolated DB tests run in
  parallel.

Co-authored-by: multica-agent <[email protected]>

* test(server): remove transport timeout unit tests

Co-authored-by: multica-agent <[email protected]>

* test(server): restore two non-timeout tests and make a bound load-bearing

Follow-up to the review of #8318. All test-only; no production code changes.

- daemon: restore the three read-liveness cases that the transport-timeout
  sweep took with it. They assert that a ping, a pong and an application
  frame each EXTEND the read deadline, so a healthy wakeup socket survives
  without traffic of our own — the deadline never fires on the happy path,
  which is why they are not transport-timeout tests. Verified they fail
  (all three) when the extension calls are removed from wakeup.go, and pass
  in 0.57s together. The pure "times out without peer traffic" case stays
  deleted.

- pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction
  pair. codex.go treats a parent deadline and a parent cancellation as one
  ending; only the Canceled arm was left covered. The fake now writes the
  secret to stderr before reading the handshake, so the check no longer
  races our own initialize write, and the budget is 2s against a measured
  430-465ms launch. Verified it fails when the !contextEnded guard is
  removed.

- pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound
  widened to a literal 5s, which the shrunk 750ms drain grace can never
  reach — the test could no longer fail. It now raises the drain grace to
  10s for its own duration and asserts under 3s: healthy runs take
  0.19-0.44s, and a broken short-circuit takes 10.19s and fails.

- wecom: gofmt the import block in relay_settle_budget_test.go.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-14 14:33:50 +08:00

815 lines
36 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" }
}]
}
}`
// scheduleHookTestManifest runs daily on purpose. Manifest validation walks
// every occurrence in a 400-day window to enforce the five-minute floor, and a
// test manifest is parsed on every write and publish: "*/5 * * * *" cost ~115k
// cron steps per parse (seconds per test under -race), a daily cron ~400.
const scheduleHookTestManifest = `{
"manifest_version": 1,
"key": "com.example.scheduled",
"name": "Scheduled",
"version": "1.0.0",
"author": { "name": "example" },
"scopes": ["net:example.com"],
"contributes": {
"hooks": [{
"key": "heartbeat",
"name": "Heartbeat",
"description": "Send a periodic heartbeat.",
"triggers": ["schedule"],
"schedule": { "cron": "0 6 * * *", "timezone": "UTC" },
"transport": { "type": "http", "url": "https://example.com/hooks/heartbeat" }
}]
}
}`
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, plugincontract.TriggerSchedule: 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_invocation WHERE installation_id IN (SELECT id FROM plugin_installation WHERE workspace_id = $1)`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM plugin_hook_schedule WHERE workspace_id = $1`, testWorkspaceID)
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 TestPluginScheduleLifecycleReconcilesAtomically(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
root := t.TempDir()
versionID := withLocalPluginSourceIn(t, root, scheduleHookTestManifest)
install, _ := json.Marshal(map[string]any{
"version_id": versionID,
"granted_scopes": []string{"net:example.com"},
})
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"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
t.Fatalf("decode installation: %v", err)
}
loadSchedule := func() (enabled bool, generation string, cron string) {
t.Helper()
if err := testPool.QueryRow(context.Background(), `
SELECT enabled, generation::text, cron_expression
FROM plugin_hook_schedule
WHERE installation_id = $1 AND hook_key = 'heartbeat'`, installed.ID).
Scan(&enabled, &generation, &cron); err != nil {
t.Fatalf("load schedule: %v", err)
}
return enabled, generation, cron
}
enabled, generation1, cron := loadSchedule()
if !enabled || cron != "0 6 * * *" {
t.Fatalf("installed schedule enabled=%v cron=%q", enabled, cron)
}
params := map[string]string{"id": testWorkspaceID, "installationId": installed.ID}
recorder = httptest.NewRecorder()
testHandler.DisablePlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/disable", nil, params))
if recorder.Code != http.StatusOK {
t.Fatalf("disable status=%d body=%s", recorder.Code, recorder.Body.String())
}
enabled, generationDisabled, _ := loadSchedule()
if enabled || generationDisabled != generation1 {
t.Fatalf("disabled schedule enabled=%v generation=%q, want disabled generation %q", enabled, generationDisabled, generation1)
}
var hasNextRun bool
if err := testPool.QueryRow(context.Background(), `
SELECT next_run_at IS NOT NULL FROM plugin_hook_schedule WHERE installation_id = $1`, installed.ID).
Scan(&hasNextRun); err != nil {
t.Fatalf("load disabled next run: %v", err)
}
if hasNextRun {
t.Fatal("disabled schedule must not advertise a next run")
}
recorder = httptest.NewRecorder()
testHandler.EnablePlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/enable", nil, params))
if recorder.Code != http.StatusOK {
t.Fatalf("enable status=%d body=%s", recorder.Code, recorder.Body.String())
}
enabled, generation2, _ := loadSchedule()
if !enabled || generation2 == generation1 {
t.Fatalf("reactivated schedule enabled=%v generation=%q, previous %q", enabled, generation2, generation1)
}
// A code-only upgrade preserves the activation generation and any in-flight
// retry. Changing cron rotates it so the new definition cannot collide with
// an execution claimed under the previous definition.
unchanged := strings.Replace(scheduleHookTestManifest, `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
writeLocalPluginManifest(t, root, unchanged)
upgradeVersionID := publishLocalPlugin(t, "hello")
upgrade, _ := json.Marshal(map[string]any{"version_id": upgradeVersionID, "granted_scopes": []string{"net:example.com"}})
recorder = httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", upgrade, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("unchanged upgrade status=%d body=%s", recorder.Code, recorder.Body.String())
}
_, generationUnchanged, _ := loadSchedule()
if generationUnchanged != generation2 {
t.Fatalf("code-only upgrade rotated generation: got %q want %q", generationUnchanged, generation2)
}
changed := strings.Replace(unchanged, `"version": "2.0.0"`, `"version": "3.0.0"`, 1)
changed = strings.Replace(changed, `"cron": "0 6 * * *"`, `"cron": "0 7 * * *"`, 1)
writeLocalPluginManifest(t, root, changed)
changedVersionID := publishLocalPlugin(t, "hello")
upgrade, _ = json.Marshal(map[string]any{"version_id": changedVersionID, "granted_scopes": []string{"net:example.com"}})
recorder = httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", upgrade, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("changed upgrade status=%d body=%s", recorder.Code, recorder.Body.String())
}
_, generation3, cron := loadSchedule()
if generation3 == generation2 || cron != "0 7 * * *" {
t.Fatalf("changed schedule generation=%q cron=%q, previous generation %q", generation3, cron, generation2)
}
removed := strings.Replace(changed, `"version": "3.0.0"`, `"version": "4.0.0"`, 1)
removed = strings.Replace(removed, `"triggers": ["schedule"],
"schedule": { "cron": "0 7 * * *", "timezone": "UTC" },`, `"triggers": ["manual"],`, 1)
writeLocalPluginManifest(t, root, removed)
removedVersionID := publishLocalPlugin(t, "hello")
upgrade, _ = json.Marshal(map[string]any{"version_id": removedVersionID, "granted_scopes": []string{"net:example.com"}})
recorder = httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", upgrade, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("schedule-removing upgrade status=%d body=%s", recorder.Code, recorder.Body.String())
}
var remaining int
if err := testPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM plugin_hook_schedule WHERE installation_id = $1`, installed.ID).Scan(&remaining); err != nil {
t.Fatalf("count removed schedules: %v", err)
}
if remaining != 0 {
t.Fatalf("schedule-removing upgrade left %d rows", remaining)
}
// Restore the schedule so uninstall exercises its own cleanup path instead
// of succeeding only because the previous upgrade already removed the row.
restored := strings.Replace(changed, `"version": "3.0.0"`, `"version": "5.0.0"`, 1)
writeLocalPluginManifest(t, root, restored)
restoredVersionID := publishLocalPlugin(t, "hello")
upgrade, _ = json.Marshal(map[string]any{"version_id": restoredVersionID, "granted_scopes": []string{"net:example.com"}})
recorder = httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", upgrade, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("schedule-restoring upgrade status=%d body=%s", recorder.Code, recorder.Body.String())
}
loadSchedule()
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())
}
if err := testPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM plugin_hook_schedule WHERE installation_id = $1`, installed.ID).Scan(&remaining); err != nil {
t.Fatalf("count schedules after uninstall: %v", err)
}
if remaining != 0 {
t.Fatalf("uninstall left %d schedule rows", remaining)
}
}
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())
}
}