mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-24 01:37:50 +08:00
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.
148 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|