- GET /plugins (backend.view_plugins, superusers always pass) lists the plugins from party.Installed, or party.Activated IDs for older builds, with the built framework version and whether the registry is configured - Settings -> System -> Plugins entry with the new SettingsEntry.path, added only for principals allowed to open it - PluginsView at /system/plugins with an Installed tab (reka-ui Tabs); settings panel, rail, breadcrumbs and page title know the route - OpenAPI and SPA types regenerated; contract, token-class and conformance inventories list the route; en/pl strings; docs
165 lines
6.7 KiB
Go
165 lines
6.7 KiB
Go
package cabana
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// pluginsConfig is an application config whose plugins.registry_url is
|
|
// registry ("" disables the registry).
|
|
func pluginsConfig(t *testing.T, registry string) *compass.Config {
|
|
t.Helper()
|
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("plugins.registry_url", registry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// pluginsService is a service over the admin's own screens and the
|
|
// screensExtender plugin, on an app with cfg that published installed
|
|
// (when not nil) and activated.
|
|
func pluginsService(t *testing.T, cfg *compass.Config, installed *party.Installed, activated party.Activated) *service {
|
|
t.Helper()
|
|
reg, err := compileWithScreens(t, []party.Plugin{screensExtender{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if installed != nil {
|
|
if err := party.Describe(app, installed.Plugins, installed.Framework); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if activated != nil {
|
|
if err := app.Publish(activated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return &service{app: app, reg: reg}
|
|
}
|
|
|
|
// pluginsCall runs handler for principal on GET target and returns the
|
|
// recorder.
|
|
func pluginsCall(svc *service, handler func(*service, http.ResponseWriter, *http.Request), principal *bouncer.Principal, target string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, adminAPI(target), nil)
|
|
if principal != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(svc, rec, req)
|
|
return rec
|
|
}
|
|
|
|
func decodeOverview(t *testing.T, rec *httptest.ResponseRecorder) PluginsOverview {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body Envelope[PluginsOverview]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
return body.Data
|
|
}
|
|
|
|
var (
|
|
pluginsSuperuser = &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
|
pluginsViewer = &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{PermissionViewPlugins: true}}
|
|
pluginsDenied = &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.ext.access": true}}
|
|
)
|
|
|
|
// testInstalled is what summer build baked for a module plugin, a
|
|
// submodule plugin and an in-app plugin without plugin.yaml.
|
|
func testInstalled() *party.Installed {
|
|
return &party.Installed{Framework: "v0.6.0", Plugins: []party.Info{
|
|
{ID: "acme.blog", Module: "example.test/acme-blog", Package: "example.test/acme-blog", Name: "Blog", Description: "Posts and categories.", Version: "v0.1.0", Source: party.SourceModule},
|
|
{ID: "acme.translate", Module: "example.test/acme-translate", Package: "example.test/acme-translate", Name: "Translate", Description: "Translations.", Version: "v0.1.1-3-gabc1234", Source: party.SourceSubmodule},
|
|
{ID: "acme.local", Package: "example.test/app/plugins/acme/local", Source: party.SourceLocal},
|
|
}}
|
|
}
|
|
|
|
// TestPluginsScreenSmoke is the Plugins screen end to end through cabana's
|
|
// handlers: who may open it, what it lists and how it links from Settings.
|
|
func TestPluginsScreenSmoke(t *testing.T) {
|
|
t.Run("installed", func(t *testing.T) {
|
|
svc := pluginsService(t, pluginsConfig(t, ""), testInstalled(), nil)
|
|
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if overview.Framework != "v0.6.0" || len(overview.Plugins) != 3 {
|
|
t.Fatalf("superuser overview = %+v", overview)
|
|
}
|
|
blog := overview.Plugins[0]
|
|
if blog.ID != "acme.blog" || blog.Name != "Blog" || blog.Version != "v0.1.0" || blog.Source != party.SourceModule || blog.Description != "Posts and categories." || blog.Module != "example.test/acme-blog" {
|
|
t.Fatalf("blog = %+v", blog)
|
|
}
|
|
if got := overview.Plugins[1]; got.Version != "v0.1.1-3-gabc1234" || got.Source != party.SourceSubmodule {
|
|
t.Fatalf("translate = %+v", got)
|
|
}
|
|
if got := overview.Plugins[2]; got.ID != "acme.local" || got.Name != "" || got.Version != "" || got.Source != party.SourceLocal {
|
|
t.Fatalf("local = %+v", got)
|
|
}
|
|
if overview.Registry.Enabled || overview.Registry.Status != registryStatusDisabled {
|
|
t.Fatalf("registry with an empty plugins.registry_url = %+v", overview.Registry)
|
|
}
|
|
|
|
// The permission opens the screen; without it the answer is 403.
|
|
if rec := pluginsCall(svc, (*service).pluginsInstalled, pluginsDenied, "/plugins"); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("without %s: status=%d body=%s", PermissionViewPlugins, rec.Code, rec.Body.String())
|
|
}
|
|
if rec := pluginsCall(svc, (*service).pluginsInstalled, nil, "/plugins"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous: status=%d", rec.Code)
|
|
}
|
|
if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsViewer, "/plugins")); len(got.Plugins) != 3 {
|
|
t.Fatalf("viewer overview = %+v", got)
|
|
}
|
|
|
|
// Settings → System links the screen only for allowed principals.
|
|
for _, tc := range []struct {
|
|
name string
|
|
principal *bouncer.Principal
|
|
want bool
|
|
}{{"superuser", pluginsSuperuser, true}, {"viewer", pluginsViewer, true}, {"denied", pluginsDenied, false}} {
|
|
rec := pluginsCall(svc, (*service).settingsList, tc.principal, "/settings")
|
|
var body Envelope[[]SettingsEntry]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, entry := range body.Data {
|
|
if entry.Code == pluginsSettingsCode {
|
|
found = true
|
|
if entry.Path != "/system/plugins" || entry.Controller != "" {
|
|
t.Fatalf("%s: plugins entry = %+v", tc.name, entry)
|
|
}
|
|
} else if entry.Path != "" {
|
|
t.Fatalf("%s: entry %s has a path %q", tc.name, entry.Code, entry.Path)
|
|
}
|
|
}
|
|
if found != tc.want {
|
|
t.Fatalf("%s: plugins entry listed=%v, want %v: %s", tc.name, found, tc.want, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// An app built before plugin metadata existed lists the activated IDs.
|
|
legacy := pluginsService(t, pluginsConfig(t, "https://registry.example.test"), nil, party.Activated{screensExtender{}})
|
|
old := decodeOverview(t, pluginsCall(legacy, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if len(old.Plugins) != 1 || old.Plugins[0].ID != "acme.ext" || old.Plugins[0].Version != "" || old.Framework == "" {
|
|
t.Fatalf("legacy overview = %+v", old)
|
|
}
|
|
if !old.Registry.Enabled {
|
|
t.Fatalf("configured registry reported %+v", old.Registry)
|
|
}
|
|
})
|
|
}
|