8792a8d0ca
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1118 lines
39 KiB
Go
1118 lines
39 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
goruntime "runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
// newTestStateStore returns an in-memory-sqlite-backed *state.Store, for
|
|
// handler tests exercising plugins that declare the state capability.
|
|
func newTestStateStore(t *testing.T) *state.Store {
|
|
t.Helper()
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
|
|
st, err := state.NewStore(db.NewPool(conn, conn))
|
|
if err != nil {
|
|
t.Fatalf("new state store: %v", err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func newTestRouter(t *testing.T) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetState(newTestStateStore(t))
|
|
// A vault is mandatory for plugins with secret config fields (Service
|
|
// fails closed without one), so wire an in-memory one, matching prod
|
|
// where Provide always attaches the shared vault.
|
|
svc.SetSecrets(newFakeSecretRevealer())
|
|
router := gin.New()
|
|
RegisterRoutes(router, svc, nil, testLogger(t))
|
|
return router, svc
|
|
}
|
|
|
|
func newTestRouterWithIdentity(t *testing.T, identity authn.Identity) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
svc, _, _ := newTestService(t)
|
|
svc.SetState(newTestStateStore(t))
|
|
// A vault is mandatory for plugins with secret config fields (Service
|
|
// fails closed without one), so wire an in-memory one, matching prod
|
|
// where Provide always attaches the shared vault.
|
|
svc.SetSecrets(newFakeSecretRevealer())
|
|
router := gin.New()
|
|
router.Use(func(ctx *gin.Context) {
|
|
authn.SetOnGin(ctx, identity)
|
|
ctx.Next()
|
|
})
|
|
RegisterRoutes(router, svc, nil, testLogger(t))
|
|
return router, svc
|
|
}
|
|
|
|
func newAdminTestRouter(t *testing.T) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
return newTestRouterWithIdentity(t, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin})
|
|
}
|
|
|
|
func doRequest(router *gin.Engine, method, path string, body string, headers map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// doAuthedRequest attaches a resolved caller identity, standing in for
|
|
// httpmw.Middleware without wiring it into focused handler tests.
|
|
func doAuthedRequest(router *gin.Engine, method, path string, body string, headers map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "user_1", Role: authn.RoleMember}))
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func doMultipartInstall(t *testing.T, router *gin.Engine, pkg *bytes.Buffer) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
w := multipart.NewWriter(&body)
|
|
part, err := w.CreateFormFile("package", "plugin.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile: %v", err)
|
|
}
|
|
if _, err := io.Copy(part, pkg); err != nil {
|
|
t.Fatalf("copy package into form: %v", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/plugins/install", &body)
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestInstallHandlerFromURLCreatesActivePlugin(t *testing.T) {
|
|
router, svc := newAdminTestRouter(t)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(testPackage(t, "kandev-plugin-slack", "1.0.0", false).Bytes())
|
|
}))
|
|
defer srv.Close()
|
|
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/install", fmt.Sprintf(`{"url":%q}`, srv.URL), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String())
|
|
}
|
|
|
|
var resp InstallResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if resp.Plugin.ID != "kandev-plugin-slack" {
|
|
t.Fatalf("Plugin.ID = %q, want %q", resp.Plugin.ID, "kandev-plugin-slack")
|
|
}
|
|
if resp.Plugin.Status != StatusActive {
|
|
t.Fatalf("Plugin.Status = %q, want %q", resp.Plugin.Status, StatusActive)
|
|
}
|
|
|
|
if _, err := svc.Get("kandev-plugin-slack"); err != nil {
|
|
t.Fatalf("plugin not persisted in service: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallHandlerRejectsMemberBeforeURLFetch(t *testing.T) {
|
|
router, _ := newTestRouterWithIdentity(t, authn.Identity{
|
|
UserID: "member-1",
|
|
Role: authn.RoleMember,
|
|
})
|
|
var fetched atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
fetched.Store(true)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/install", fmt.Sprintf(`{"url":%q}`, srv.URL), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
|
}
|
|
if fetched.Load() {
|
|
t.Fatal("member-controlled install URL reached the network")
|
|
}
|
|
}
|
|
|
|
func TestInstallHandlerMultipartUpload(t *testing.T) {
|
|
router, svc := newAdminTestRouter(t)
|
|
|
|
rec := doMultipartInstall(t, router, testPackage(t, "kandev-plugin-slack", "1.0.0", false))
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String())
|
|
}
|
|
if _, err := svc.Get("kandev-plugin-slack"); err != nil {
|
|
t.Fatalf("plugin not persisted in service: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstallHandlerMissingURLReturns400(t *testing.T) {
|
|
router, _ := newAdminTestRouter(t)
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/install", `{}`, nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstallHandlerDuplicateVersionReturns409(t *testing.T) {
|
|
router, _ := newAdminTestRouter(t)
|
|
pkg := testPackage(t, "kandev-plugin-slack", "1.0.0", false)
|
|
pkgBytes := pkg.Bytes()
|
|
|
|
first := doMultipartInstall(t, router, bytes.NewBuffer(pkgBytes))
|
|
if first.Code != http.StatusCreated {
|
|
t.Fatalf("first install status = %d, want 201, body=%s", first.Code, first.Body.String())
|
|
}
|
|
|
|
second := doMultipartInstall(t, router, bytes.NewBuffer(pkgBytes))
|
|
if second.Code != http.StatusConflict {
|
|
t.Fatalf("second install status = %d, want 409, body=%s", second.Code, second.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListHandlerReturnsInstalledPlugins(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
installTestPlugin(t, svc, "kandev-plugin-slack")
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var body struct {
|
|
Plugins []*store.Record `json:"plugins"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(body.Plugins) != 1 {
|
|
t.Fatalf("len(plugins) = %d, want 1", len(body.Plugins))
|
|
}
|
|
}
|
|
|
|
// TestListHandlerExposesUIKeybindings confirms that ui.keybindings declared
|
|
// in the manifest round-trips through GET /api/plugins into the JSON each
|
|
// plugin entry carries for the frontend (record.ui.keybindings, since
|
|
// store.Record embeds manifest.Manifest inline).
|
|
func TestListHandlerExposesUIKeybindings(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: kandev-plugin-keybound
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin
|
|
capabilities:
|
|
events: ["task.*"]
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
ui:
|
|
bundle: "/ui/bundle.js"
|
|
keybindings:
|
|
- id: open-demo
|
|
default: mod+shift+k
|
|
description: Open the demo modal
|
|
`, platformKey)
|
|
var buf bytes.Buffer
|
|
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
"ui/bundle.js": []byte("export default {};"),
|
|
}); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
if _, err := svc.Install(context.Background(), &buf); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
|
|
var body struct {
|
|
Plugins []struct {
|
|
UI struct {
|
|
Keybindings []struct {
|
|
ID string `json:"id"`
|
|
Default string `json:"default"`
|
|
Description string `json:"description"`
|
|
} `json:"keybindings"`
|
|
} `json:"ui"`
|
|
} `json:"plugins"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(body.Plugins) != 1 {
|
|
t.Fatalf("len(plugins) = %d, want 1", len(body.Plugins))
|
|
}
|
|
kbs := body.Plugins[0].UI.Keybindings
|
|
if len(kbs) != 1 {
|
|
t.Fatalf("len(ui.keybindings) = %d, want 1", len(kbs))
|
|
}
|
|
if kbs[0].ID != "open-demo" || kbs[0].Default != "mod+shift+k" || kbs[0].Description != "Open the demo modal" {
|
|
t.Fatalf("ui.keybindings[0] = %+v, want id=open-demo default=mod+shift+k description=%q", kbs[0], "Open the demo modal")
|
|
}
|
|
}
|
|
|
|
func TestGetHandlerMissingReturns404(t *testing.T) {
|
|
router, _ := newTestRouter(t)
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/missing", "", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestEnableDisableHandlersTransitionStatus(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
installTestPlugin(t, svc, "kandev-plugin-slack") // already active after install
|
|
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/disable", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("disable status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
got, _ := svc.Get("kandev-plugin-slack")
|
|
if got.Status != StatusDisabled {
|
|
t.Fatalf("status after disable = %q, want %q", got.Status, StatusDisabled)
|
|
}
|
|
|
|
rec = doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/enable", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("enable status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
got, _ = svc.Get("kandev-plugin-slack")
|
|
if got.Status != StatusActive {
|
|
t.Fatalf("status after enable = %q, want %q", got.Status, StatusActive)
|
|
}
|
|
}
|
|
|
|
func TestUpdateConfigHandlerPersists(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
installTestPlugin(t, svc, "kandev-plugin-slack")
|
|
|
|
rec := doRequest(router, http.MethodPatch, "/api/plugins/kandev-plugin-slack", `{"config":{"default_channel":"#dev"}}`, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUninstallHandlerRemovesPlugin(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
installTestPlugin(t, svc, "kandev-plugin-slack")
|
|
|
|
rec := doRequest(router, http.MethodDelete, "/api/plugins/kandev-plugin-slack", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if _, err := svc.Get("kandev-plugin-slack"); err == nil {
|
|
t.Fatal("plugin still present after uninstall")
|
|
}
|
|
}
|
|
|
|
func TestBundleHandlerServesFileFromDisk(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/bundle", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
|
|
t.Fatalf("Content-Type = %q, want text/javascript; charset=utf-8", got)
|
|
}
|
|
if rec.Body.String() != "export default {};" {
|
|
t.Fatalf("body = %q, want the bundle file contents", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUIHandlerServesStyleFileFromDisk(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/ui/ui/style.css", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != "body{}" {
|
|
t.Fatalf("body = %q, want the style file contents", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUIHandlerPathTraversalRejected(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/ui/../../../../etc/passwd", "", nil)
|
|
if rec.Code == http.StatusOK {
|
|
t.Fatalf("status = 200, want a non-200 response for a path-traversal attempt, body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBundleHandlerInactivePluginReturns503(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if err := svc.Disable("kandev-plugin-ui"); err != nil {
|
|
t.Fatalf("Disable: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/bundle", "", nil)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// webhookPackage builds a valid runtime-managed package that declares
|
|
// exactly one webhook with the given key, for POST/GET
|
|
// /api/plugins/:id/webhooks/:key tests.
|
|
func webhookPackage(t *testing.T, id, key string) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin
|
|
webhooks:
|
|
- key: %s
|
|
method: POST
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, key, platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
files := map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}
|
|
if err := pkgtartest.WritePackage(&buf, files); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
func authenticatedWebhookPackage(t *testing.T, id, key string, maxBodyBytes int64) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin
|
|
webhooks:
|
|
- key: %s
|
|
access: authenticated
|
|
max_body_bytes: %d
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, key, maxBodyBytes, platformKey)
|
|
var buf bytes.Buffer
|
|
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
func TestWebhookHandlerUnknownPluginReturns404(t *testing.T) {
|
|
router, _ := newTestRouter(t)
|
|
rec := doAuthedRequest(router, http.MethodPost, "/api/plugins/missing/webhooks/key1", "{}", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookHandlerUndeclaredKeyReturns404 pins the fix that the webhook
|
|
// relay validates :key against the plugin's manifest-declared webhooks
|
|
// before ever reaching the live subprocess: a key the plugin never
|
|
// registered must 404, not be blindly forwarded.
|
|
func TestWebhookHandlerUndeclaredKeyReturns404(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "declared-key")); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doAuthedRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/undeclared-key", "{}", nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWebhookHandlerOversizedBodyReturns413 pins the fix that the webhook
|
|
// relay caps the request body instead of an unbounded io.ReadAll (an
|
|
// external-caller-triggerable OOM DoS otherwise).
|
|
func TestWebhookHandlerOversizedBodyReturns413(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
oversized := strings.Repeat("a", int(maxWebhookBodyBytes+1))
|
|
rec := doAuthedRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", oversized, nil)
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedWebhookRejectsAnonymousRequest(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), authenticatedWebhookPackage(t, "kandev-plugin-voice", "transcribe", 16<<20)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-voice/webhooks/transcribe", "audio", nil)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReadCappedWebhookBodyUsesDeclaredLimit(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(rec)
|
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345"))
|
|
_, err := readCappedWebhookBody(ctx, 4)
|
|
if err == nil || rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("error = %v, status = %d, want size error and 413", err, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestWebhookRelayHeadersStripHostCredentials(t *testing.T) {
|
|
headers := http.Header{
|
|
"Authorization": []string{"Bearer kandev_pat_secret"},
|
|
"Cookie": []string{"kandev_session=secret"},
|
|
"Content-Type": []string{"audio/webm"},
|
|
"X-Plugin-Key": []string{"plugin-secret"},
|
|
}
|
|
|
|
got := flattenHeaders(headers, "kandev_session", false)
|
|
|
|
if _, ok := got["Authorization"]; ok {
|
|
t.Fatal("Authorization header was forwarded to plugin")
|
|
}
|
|
if _, ok := got["Cookie"]; ok {
|
|
t.Fatal("Cookie header was forwarded to plugin")
|
|
}
|
|
if got["Content-Type"] != "audio/webm" || got["X-Plugin-Key"] != "plugin-secret" {
|
|
t.Fatalf("relay-safe headers = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestWebhookHandlerNotRunningReturns503(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if err := svc.Disable("kandev-plugin-slack"); err != nil {
|
|
t.Fatalf("Disable: %v", err)
|
|
}
|
|
|
|
rec := doAuthedRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", "{}", nil)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
type recordingActionInvoker struct {
|
|
calls int
|
|
request *pluginsdk.PluginActionRequest
|
|
generation pluginDispatchGeneration
|
|
response *pluginsdk.PluginActionResponse
|
|
err error
|
|
invoke func(context.Context, string, *pluginsdk.PluginActionRequest) (*pluginsdk.PluginActionResponse, error)
|
|
}
|
|
|
|
func (f *recordingActionInvoker) InvokeAction(
|
|
ctx context.Context, id string, generation pluginDispatchGeneration, req *pluginsdk.PluginActionRequest,
|
|
) (*pluginsdk.PluginActionResponse, error) {
|
|
f.calls++
|
|
f.request = req
|
|
f.generation = generation
|
|
if f.invoke != nil {
|
|
return f.invoke(ctx, id, req)
|
|
}
|
|
return f.response, f.err
|
|
}
|
|
|
|
// actionPackage builds a valid runtime-managed package with one declared,
|
|
// workspace-scoped action. Its small, explicit body cap makes the action
|
|
// relay's cap behavior testable without large test inputs.
|
|
func actionPackage(t *testing.T, id, key, resourceScope string, maxBodyBytes int) *bytes.Buffer {
|
|
t.Helper()
|
|
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: %s
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Test Plugin
|
|
actions:
|
|
- key: %s
|
|
resource_scope: %s
|
|
max_body_bytes: %d
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, id, key, resourceScope, maxBodyBytes, platformKey)
|
|
|
|
var buf bytes.Buffer
|
|
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
|
|
"manifest.yaml": []byte(manifestYAML),
|
|
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
|
|
}); err != nil {
|
|
t.Fatalf("WritePackage: %v", err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
func newActionTestRouter(t *testing.T, invoker *recordingActionInvoker) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
svc, _, _ := newTestService(t)
|
|
svc.taskData = &fakeTaskDataSource{
|
|
workspaces: []*taskmodels.Workspace{{ID: "workspace-1"}},
|
|
}
|
|
router := gin.New()
|
|
ctrl := &Controller{svc: svc, log: testLogger(t), actionInvoker: invoker}
|
|
router.POST("/api/plugins/:id/actions/:key", ctrl.action)
|
|
return router, svc
|
|
}
|
|
|
|
func doAuthenticatedActionRequest(router *gin.Engine, path, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(authn.WithIdentity(req.Context(), authn.Identity{UserID: "user-1", Role: authn.RoleMember}))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// TestActionHandlerDispatchesVerifiedContext proves browser supplied scope
|
|
// selectors are verified by the host and that plugins receive the trusted
|
|
// actor/workspace context, never raw authentication material.
|
|
func TestActionHandlerDispatchesVerifiedContext(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{Body: []byte(`{"ok":true}`)}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "create-task", "workspace", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
rec := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/create-task",
|
|
`{"workspaceId":"workspace-1","body":{"title":"Ship it"}}`,
|
|
)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if invoker.calls != 1 {
|
|
t.Fatalf("action invocations = %d, want 1", invoker.calls)
|
|
}
|
|
if got := invoker.request.Context; got.ActorID != "user-1" || got.WorkspaceID != "workspace-1" || got.TaskID != "" || got.RepositoryID != "" {
|
|
t.Fatalf("verified action context = %+v, want actor=user-1 workspace=workspace-1", got)
|
|
}
|
|
if got := string(invoker.request.Body); got != `{"title":"Ship it"}` {
|
|
t.Fatalf("action body = %s, want original action body", got)
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerRejectsUndeclaredAndUnauthorizedResources(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{Body: []byte(`{}`)}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "declared", "workspace", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
undeclared := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/undeclared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if undeclared.Code != http.StatusNotFound {
|
|
t.Fatalf("undeclared status = %d, want 404, body=%s", undeclared.Code, undeclared.Body.String())
|
|
}
|
|
|
|
unauthorized := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-not-visible","body":{}}`,
|
|
)
|
|
if unauthorized.Code != http.StatusNotFound {
|
|
t.Fatalf("unauthorized status = %d, want 404, body=%s", unauthorized.Code, unauthorized.Body.String())
|
|
}
|
|
if invoker.calls != 0 {
|
|
t.Fatalf("action invocations = %d, want 0", invoker.calls)
|
|
}
|
|
|
|
unauthenticatedReq := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
strings.NewReader(`{"workspaceId":"workspace-1","body":{}}`),
|
|
)
|
|
unauthenticatedReq.Header.Set("Content-Type", "application/json")
|
|
unauthenticated := httptest.NewRecorder()
|
|
router.ServeHTTP(unauthenticated, unauthenticatedReq)
|
|
if unauthenticated.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated status = %d, want 401, body=%s", unauthenticated.Code, unauthenticated.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerDerivesTaskWorkspaceAndRejectsMismatch(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{Body: []byte(`{}`)}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
svc.taskData = &fakeTaskDataSource{
|
|
tasksByID: map[string]*taskmodels.Task{
|
|
"task-1": {ID: "task-1", WorkspaceID: "workspace-1"},
|
|
},
|
|
}
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "review", "task", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
valid := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/review",
|
|
`{"taskId":"task-1","body":{}}`,
|
|
)
|
|
if valid.Code != http.StatusOK {
|
|
t.Fatalf("valid task status = %d, want 200, body=%s", valid.Code, valid.Body.String())
|
|
}
|
|
if got := invoker.request.Context; got.WorkspaceID != "workspace-1" || got.TaskID != "task-1" {
|
|
t.Fatalf("verified task context = %+v, want workspace-1/task-1", got)
|
|
}
|
|
|
|
mismatch := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/review",
|
|
`{"workspaceId":"workspace-not-visible","taskId":"task-1","body":{}}`,
|
|
)
|
|
if mismatch.Code != http.StatusBadRequest {
|
|
t.Fatalf("mismatch status = %d, want 400, body=%s", mismatch.Code, mismatch.Body.String())
|
|
}
|
|
if invoker.calls != 1 {
|
|
t.Fatalf("action invocations = %d, want 1", invoker.calls)
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerVerifiesOptionalTaskRepository(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{Body: []byte(`{}`)}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
svc.taskData = &fakeTaskDataSource{
|
|
tasksByID: map[string]*taskmodels.Task{
|
|
"task-1": {
|
|
ID: "task-1",
|
|
WorkspaceID: "workspace-1",
|
|
Repositories: []*taskmodels.TaskRepository{
|
|
{RepositoryID: "repository-1"},
|
|
},
|
|
},
|
|
},
|
|
sessionsByTask: map[string][]*taskmodels.TaskSession{
|
|
"task-1": {{
|
|
ID: "session-1",
|
|
TaskID: "task-1",
|
|
Worktrees: []*taskmodels.TaskEnvironmentRepo{{
|
|
RepositoryID: "repository-1",
|
|
WorktreeBranch: "feature/native-create",
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "create-review", "task", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
valid := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/create-review",
|
|
`{"workspaceId":"workspace-1","taskId":"task-1","sessionId":"session-1","repositoryId":"repository-1","body":{}}`,
|
|
)
|
|
if valid.Code != http.StatusOK {
|
|
t.Fatalf("task repository status = %d, want 200, body=%s", valid.Code, valid.Body.String())
|
|
}
|
|
if got := invoker.request.Context; got.WorkspaceID != "workspace-1" || got.TaskID != "task-1" || got.RepositoryID != "repository-1" {
|
|
t.Fatalf("verified task repository context = %+v", got)
|
|
}
|
|
if got := invoker.request.Context.SessionID; got != "session-1" {
|
|
t.Fatalf("verified session = %q, want session-1", got)
|
|
}
|
|
if got := invoker.request.Context.HeadBranch; got != "feature/native-create" {
|
|
t.Fatalf("verified head branch = %q, want feature/native-create", got)
|
|
}
|
|
|
|
unverifiedSession := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/create-review",
|
|
`{"workspaceId":"workspace-1","taskId":"task-1","sessionId":"session-not-on-task","repositoryId":"repository-1","body":{}}`,
|
|
)
|
|
if unverifiedSession.Code != http.StatusNotFound {
|
|
t.Fatalf("unverified session status = %d, want 404, body=%s", unverifiedSession.Code, unverifiedSession.Body.String())
|
|
}
|
|
|
|
unattached := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/create-review",
|
|
`{"workspaceId":"workspace-1","taskId":"task-1","repositoryId":"repository-2","body":{}}`,
|
|
)
|
|
if unattached.Code != http.StatusNotFound {
|
|
t.Fatalf("unattached repository status = %d, want 404, body=%s", unattached.Code, unattached.Body.String())
|
|
}
|
|
if invoker.calls != 1 {
|
|
t.Fatalf("action invocations = %d, want 1", invoker.calls)
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerVerifiesRepositoryScope(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{Body: []byte(`{}`)}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
svc.taskData = &fakeTaskDataSource{
|
|
repositories: map[string][]*taskmodels.Repository{
|
|
"workspace-1": {{ID: "repository-1", WorkspaceID: "workspace-1"}},
|
|
},
|
|
}
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "browse", "repository", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
valid := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/browse",
|
|
`{"workspaceId":"workspace-1","repositoryId":"repository-1","body":{}}`,
|
|
)
|
|
if valid.Code != http.StatusOK {
|
|
t.Fatalf("repository status = %d, want 200, body=%s", valid.Code, valid.Body.String())
|
|
}
|
|
if got := invoker.request.Context; got.WorkspaceID != "workspace-1" || got.RepositoryID != "repository-1" || got.TaskID != "" {
|
|
t.Fatalf("verified repository context = %+v, want workspace-1/repository-1", got)
|
|
}
|
|
|
|
missing := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/browse",
|
|
`{"workspaceId":"workspace-1","repositoryId":"not-visible","body":{}}`,
|
|
)
|
|
if missing.Code != http.StatusNotFound {
|
|
t.Fatalf("missing repository status = %d, want 404, body=%s", missing.Code, missing.Body.String())
|
|
}
|
|
if invoker.calls != 1 {
|
|
t.Fatalf("action invocations = %d, want 1", invoker.calls)
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerEnforcesBodyResponseAndHeaderLimits(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{
|
|
Headers: map[string]string{
|
|
"Cache-Control": "no-store",
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"Set-Cookie": "session=attacker",
|
|
"X-Plugin": "untrusted",
|
|
},
|
|
Body: []byte("safe"),
|
|
}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "declared", "workspace", 16)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
overBody := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
fmt.Sprintf(`{"workspaceId":"workspace-1","body":%q}`, strings.Repeat("x", 17)),
|
|
)
|
|
if overBody.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized body status = %d, want 413, body=%s", overBody.Code, overBody.Body.String())
|
|
}
|
|
if invoker.calls != 0 {
|
|
t.Fatalf("action invocations after oversized body = %d, want 0", invoker.calls)
|
|
}
|
|
|
|
valid := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if valid.Code != http.StatusOK {
|
|
t.Fatalf("safe response status = %d, want 200, body=%s", valid.Code, valid.Body.String())
|
|
}
|
|
if got := valid.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Fatalf("Content-Type = %q, want allowed plugin value", got)
|
|
}
|
|
if got := valid.Header().Get("Cache-Control"); got != "no-store" {
|
|
t.Fatalf("Cache-Control = %q, want allowed plugin value", got)
|
|
}
|
|
if got := valid.Header().Get("Set-Cookie"); got != "" {
|
|
t.Fatalf("Set-Cookie = %q, want blocked", got)
|
|
}
|
|
if got := valid.Header().Get("X-Plugin"); got != "" {
|
|
t.Fatalf("X-Plugin = %q, want blocked", got)
|
|
}
|
|
|
|
invoker.response = &pluginsdk.PluginActionResponse{Body: make([]byte, maxPluginActionResponseBytes+1)}
|
|
overResponse := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if overResponse.Code != http.StatusBadGateway {
|
|
t.Fatalf("oversized response status = %d, want 502, body=%s", overResponse.Code, overResponse.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerPreservesValidatedDomainStatusAndRetryHeader(t *testing.T) {
|
|
invoker := &recordingActionInvoker{response: &pluginsdk.PluginActionResponse{
|
|
Status: http.StatusTooManyRequests,
|
|
Headers: map[string]string{
|
|
"Content-Type": "application/json",
|
|
"ETag": `"review-42"`,
|
|
"Retry-After": "30",
|
|
},
|
|
Body: []byte(`{"error":"rate_limited"}`),
|
|
}}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "declared", "workspace", 16)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
response := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if response.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d, want 429, body=%s", response.Code, response.Body.String())
|
|
}
|
|
if got := response.Header().Get("Retry-After"); got != "30" {
|
|
t.Fatalf("Retry-After = %q, want 30", got)
|
|
}
|
|
if got := response.Header().Get("ETag"); got != `"review-42"` {
|
|
t.Fatalf("ETag = %q, want review-42", got)
|
|
}
|
|
|
|
invoker.response.Status = http.StatusContinue
|
|
invalid := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if invalid.Code != http.StatusBadGateway {
|
|
t.Fatalf("invalid plugin status = %d, want 502, body=%s", invalid.Code, invalid.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestActionHandlerTimeoutCancelsPluginCallAndLifecycleRevokesAccess(t *testing.T) {
|
|
invoker := &recordingActionInvoker{}
|
|
router, svc := newActionTestRouter(t, invoker)
|
|
if _, err := svc.Install(t.Context(), actionPackage(t, "kandev-plugin-actions", "declared", "workspace", 128)); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
oldTimeout := pluginActionTimeout
|
|
pluginActionTimeout = 5 * time.Millisecond
|
|
t.Cleanup(func() { pluginActionTimeout = oldTimeout })
|
|
canceled := false
|
|
invoker.invoke = func(ctx context.Context, _ string, _ *pluginsdk.PluginActionRequest) (*pluginsdk.PluginActionResponse, error) {
|
|
<-ctx.Done()
|
|
canceled = errors.Is(ctx.Err(), context.DeadlineExceeded)
|
|
return nil, ctx.Err()
|
|
}
|
|
timedOut := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if timedOut.Code != http.StatusGatewayTimeout || !canceled {
|
|
t.Fatalf("timeout status/cancellation = %d/%t, want 504/true, body=%s", timedOut.Code, canceled, timedOut.Body.String())
|
|
}
|
|
|
|
if err := svc.Disable("kandev-plugin-actions"); err != nil {
|
|
t.Fatalf("Disable: %v", err)
|
|
}
|
|
revoked := doAuthenticatedActionRequest(
|
|
router,
|
|
"/api/plugins/kandev-plugin-actions/actions/declared",
|
|
`{"workspaceId":"workspace-1","body":{}}`,
|
|
)
|
|
if revoked.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("revoked status = %d, want 503, body=%s", revoked.Code, revoked.Body.String())
|
|
}
|
|
if invoker.calls != 1 {
|
|
t.Fatalf("action invocations = %d, want 1 after revoked request", invoker.calls)
|
|
}
|
|
}
|
|
|
|
// TestWebhookStatusForResponse_ValidStatusesPassThrough pins that any
|
|
// syntactically valid HTTP status code a plugin returns is relayed
|
|
// unchanged.
|
|
func TestWebhookStatusForResponse_ValidStatusesPassThrough(t *testing.T) {
|
|
for _, status := range []int32{100, 200, 204, 302, 404, 500, 599} {
|
|
got, ok := webhookStatusForResponse(status)
|
|
if !ok {
|
|
t.Fatalf("webhookStatusForResponse(%d) ok = false, want true", status)
|
|
}
|
|
if got != int(status) {
|
|
t.Fatalf("webhookStatusForResponse(%d) = %d, want %d", status, got, status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookStatusForResponse_OutOfRangeStatusesAreRejected pins the fix
|
|
// for a plugin returning a status outside [100, 599]: gin's WriteHeader
|
|
// panics (-> 500) on such a code, so the webhook relay must reject it
|
|
// before ever calling WriteHeader.
|
|
func TestWebhookStatusForResponse_OutOfRangeStatusesAreRejected(t *testing.T) {
|
|
for _, status := range []int32{0, -1, 99, 600, 1000} {
|
|
if _, ok := webhookStatusForResponse(status); ok {
|
|
t.Fatalf("webhookStatusForResponse(%d) ok = true, want false (out of [100,599])", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteWebhookResponse_OutOfRangePluginStatusReturns502 exercises the
|
|
// exact code path the webhook handler uses to turn a plugin's
|
|
// WebhookResponse into the outbound HTTP response: given an out-of-range
|
|
// plugin status, it must write 502 instead of ever calling
|
|
// ctx.Writer.WriteHeader with the invalid code (gin panics -> bare 500 on
|
|
// an out-of-range WriteHeader call).
|
|
func TestWriteWebhookResponse_OutOfRangePluginStatusReturns502(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(rec)
|
|
|
|
ctrl := &Controller{svc: &Service{}, log: testLogger(t)}
|
|
ctrl.writeWebhookResponse(ctx, &store.Record{}, &pluginsdk.WebhookResponse{Status: 0, Body: []byte("boom")})
|
|
|
|
if rec.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestWriteWebhookResponse_ValidStatusRelaysHeadersAndBody proves the
|
|
// normal (in-range) path still relays the plugin's status, headers, and
|
|
// body verbatim.
|
|
func TestWriteWebhookResponse_ValidStatusRelaysHeadersAndBody(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(rec)
|
|
|
|
ctrl := &Controller{svc: &Service{}, log: testLogger(t)}
|
|
ctrl.writeWebhookResponse(ctx, &store.Record{}, &pluginsdk.WebhookResponse{
|
|
Status: 201,
|
|
Headers: map[string]string{"X-Plugin": "slack"},
|
|
Body: []byte(`{"ok":true}`),
|
|
})
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("X-Plugin"); got != "slack" {
|
|
t.Fatalf("X-Plugin header = %q, want %q", got, "slack")
|
|
}
|
|
if rec.Body.String() != `{"ok":true}` {
|
|
t.Fatalf("body = %q, want %q", rec.Body.String(), `{"ok":true}`)
|
|
}
|
|
}
|
|
|
|
func TestSyncHandlerRegistersDirSideload(t *testing.T) {
|
|
router, svc := newTestRouter(t)
|
|
pluginsDir := svc.pluginsDir
|
|
versionDir := filepath.Join(pluginsDir, "kandev-plugin-side", "1.0.0")
|
|
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
manifestYAML := fmt.Sprintf(`
|
|
id: kandev-plugin-side
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: Sideloaded
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
%s: server/plugin
|
|
`, goruntime.GOOS+"-"+goruntime.GOARCH)
|
|
if err := os.WriteFile(filepath.Join(versionDir, "manifest.yaml"), []byte(manifestYAML), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
rec := doRequest(router, http.MethodPost, "/api/plugins/sync", "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var resp SyncResult
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if len(resp.Added) != 1 || resp.Added[0] != "kandev-plugin-side" {
|
|
t.Fatalf("Added = %v, want [kandev-plugin-side]", resp.Added)
|
|
}
|
|
|
|
got, err := svc.Get("kandev-plugin-side")
|
|
if err != nil {
|
|
t.Fatalf("Get(): %v", err)
|
|
}
|
|
if got.Status != StatusDisabled {
|
|
t.Fatalf("Status = %q, want %q", got.Status, StatusDisabled)
|
|
}
|
|
}
|