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.StatusForbidden { 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()) } }