mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(plugin): return 403 when API is disabled (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): classify disabled capabilities as non-retryable (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): address disabled capability review feedback (MUL-7437) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
815 lines
36 KiB
Go
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.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())
|
|
}
|
|
}
|