Files
WeKnora/internal/handler/plugin_forms_test.go
T
wizardchen d46fa25e41 feat(plugin): dynamic form options, OAuth connections and resource limits
Plugin config forms can now ask the plugin while they are filled in:

- x-options {name, dependsOn, search} loads a field's choices from
  /v1/options/{name}. POST /api/v1/plugins/:id/options merges the form's
  values with the stored secrets (system, tenant or instance scope) so a
  redacted token still works. Go and Python SDKs, OpenAPI and conformance
  cover the endpoint.
- x-oauth turns a string field into an account connection. WeKnora runs
  the authorization code flow (PKCE, client credentials from the plugin's
  system config), keeps the tokens sealed in plugin_oauth_connections and
  the field holds "oauth:<id>". The invoker swaps references for fresh
  access tokens at call time, refreshing near expiry. Callback:
  /api/v1/plugin-oauth/callback, one-time states in Redis or memory.
- SchemaForm gains an options widget (dependsOn reload, remote search)
  and an OAuth connect widget; connector, web search, workspace and
  platform config forms provide the plugin behind them.

Host plugins are capped per runtime.resources on Linux: RLIMIT_DATA for
memory (WEKNORA_PLUGIN_MEMORY_DEFAULT when undeclared) and, with a
delegated cgroup v2 (WEKNORA_PLUGIN_CGROUP), memory.max and cpu.max.
2026-09-26 11:51:35 +08:00

148 lines
5.6 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/plugin/activate"
"github.com/Tencent/WeKnora/internal/plugin/manifest"
pluginoauth "github.com/Tencent/WeKnora/internal/plugin/oauth"
"github.com/Tencent/WeKnora/internal/plugin/plugintest"
"github.com/Tencent/WeKnora/internal/plugin/registry"
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/pluginsdk"
"github.com/Tencent/WeKnora/pluginsdk/client"
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
)
func formsEngine(t *testing.T) (*gin.Engine, *plugintest.MemTenantSettings) {
t.Helper()
gin.SetMode(gin.TestMode)
tenantSchema, _ := json.Marshal(map[string]any{"type": "object", "properties": map[string]any{
"site": map[string]any{"type": "string"},
"token": map[string]any{"type": "string", "x-secret": true},
"project": map[string]any{
"type": "string",
"x-options": map[string]any{"name": "projects", "dependsOn": []string{"site", "token"}},
},
}})
m := &manifest.Manifest{
SchemaVersion: manifest.SchemaVersion, ID: "acme.jira", Version: "1.0.0", APIVersion: pluginapi.APIVersion,
Name: manifest.Text("Jira", nil), Publisher: manifest.Publisher{ID: "acme"},
Runtime: manifest.Runtime{Type: manifest.RuntimeHost, Kind: "binary", Entry: "bin/x"},
Contributes: manifest.Contributions{manifest.PointWebSearch: {{ID: "x", Name: manifest.Text("X", nil)}}},
Config: manifest.ConfigSchemas{TenantSchema: tenantSchema},
}
reg := registry.New()
if err := reg.Register(m); err != nil {
t.Fatal(err)
}
settings := &plugintest.MemTenantSettings{}
ten := tenancy.NewService(reg, settings)
ctx := context.Background()
_ = settings.Upsert(ctx, &types.PluginTenantSetting{TenantID: 7, PluginID: "acme.jira", Enabled: true})
stored := map[string]any{"site": "old", "token": "stored-secret"}
if _, err := ten.SetConfig(ctx, 7, "acme.jira", stored, "u"); err != nil {
t.Fatal(err)
}
p := pluginsdk.New(pluginsdk.Info{ID: "acme.jira", Version: "1.0.0"})
p.Options(
"projects",
func(_ context.Context, call *pluginsdk.Call, in pluginapi.OptionsInput) ([]pluginapi.Option, error) {
if call.Config.Tenant["token"] != "stored-secret" {
return nil, pluginapi.Errorf(pluginapi.CodeUnauthorized, "bad token %v", call.Config.Tenant["token"])
}
site, _ := call.Config.Tenant["site"].(string)
return []pluginapi.Option{{Value: site + "/" + in.Query, Label: in.Field}}, nil
},
)
srv := httptest.NewServer(p.Handler())
t.Cleanup(srv.Close)
h := NewPluginFormsHandler(reg, ten, activate.NewInvoker(oneClient{client.New(srv.URL, nil, nil)}),
plugintest.NewMemRepo(), nil, nil, nil)
r := gin.New()
r.Use(middleware.ErrorHandler())
r.POST("/plugins/:id/options", func(c *gin.Context) {
c.Set(types.TenantIDContextKey.String(), uint64(7))
h.Options(c)
})
return r, settings
}
func TestPluginFormOptions(t *testing.T) {
r, settings := formsEngine(t)
post := func(body string) *httptest.ResponseRecorder {
return call(
r,
http.MethodPost,
"/plugins/acme.jira/options",
body,
map[string]string{"Content-Type": "application/json"},
)
}
// The form's unsaved site is used; its redacted token is the stored one.
w := post(
`{"name":"projects","field":"project","scope":"tenant","query":"q","values":{"site":"new","token":"***"}}`,
)
var resp struct {
Data pluginapi.OptionsOutput `json:"data"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &resp) != nil ||
len(
resp.Data.Options,
) != 1 || resp.Data.Options[0].Value != "new/q" || resp.Data.Options[0].Label != "project" {
t.Fatalf("options = %d %s", w.Code, w.Body)
}
// A token the user typed replaces the stored one; the plugin's refusal
// reaches the form as a 400.
w = post(`{"name":"projects","field":"project","scope":"tenant","values":{"token":"typed"}}`)
if w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "unauthorized") {
t.Fatalf("typed token = %d %s", w.Code, w.Body)
}
if w := post(`{"name":"nope","field":"project","scope":"tenant"}`); w.Code != http.StatusNotFound {
t.Fatalf("unknown source = %d %s", w.Code, w.Body)
}
if w := post(`{"name":"projects","field":"project","scope":"system"}`); w.Code != http.StatusForbidden {
t.Fatalf("system scope for a workspace admin = %d", w.Code)
}
if w := post(`{"name":"projects","field":"project","scope":"other"}`); w.Code != http.StatusBadRequest {
t.Fatalf("bad scope = %d", w.Code)
}
_ = settings.Upsert(context.Background(),
&types.PluginTenantSetting{TenantID: 7, PluginID: "acme.jira", Enabled: false}, "enabled")
if w := post(`{"name":"projects","field":"project","scope":"tenant"}`); w.Code != http.StatusNotFound {
t.Fatalf("a switched-off plugin = %d", w.Code)
}
}
func TestPluginOAuthCallbackPage(t *testing.T) {
gin.SetMode(gin.TestMode)
reg := registry.New()
h := NewPluginFormsHandler(reg, nil, nil, nil, nil, nil,
pluginoauth.NewService(reg, plugintest.NewMemRepo(), nil, nil))
r := gin.New()
r.GET(pluginoauth.CallbackPath, h.OAuthCallback)
w := call(r, http.MethodGet, pluginoauth.CallbackPath+"?state=%3C%2Fscript%3E&code=x", "", nil)
body := w.Body.String()
if w.Code != http.StatusBadRequest || w.Header().Get("Cache-Control") != "no-store" ||
strings.Contains(body, "</script>'") || !strings.Contains(body, "ok: false") {
t.Fatalf("callback = %d %s", w.Code, body)
}
// Without a known state there is no origin to post the result to.
if !strings.Contains(body, `window.opener && ""`) {
t.Fatalf("callback posts to an origin: %s", body)
}
}