diff --git a/modules/cabana/plugins.go b/modules/cabana/plugins.go index b09a51a5..3b24e535 100644 --- a/modules/cabana/plugins.go +++ b/modules/cabana/plugins.go @@ -456,11 +456,15 @@ func (s *service) pluginsMarketplace(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// categoriesOf returns the distinct valid categories of plugins, sorted. +// categoriesOf returns the distinct valid categories of the plugins the +// marketplace offers (valid IDs only), sorted. func categoriesOf(plugins []kiosk.RegistryPlugin) []string { seen := map[string]bool{} out := []string{} for _, p := range plugins { + if !marketplaceIDRe.MatchString(p.ID) { + continue + } if p.Category != "" && marketplaceCategoryRe.MatchString(p.Category) && !seen[p.Category] { seen[p.Category] = true out = append(out, p.Category) diff --git a/modules/cabana/plugins_test.go b/modules/cabana/plugins_test.go new file mode 100644 index 00000000..0f90a8f5 --- /dev/null +++ b/modules/cabana/plugins_test.go @@ -0,0 +1,473 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/kiosk" + "git.golem15.com/golem15/summercms/modules/party" +) + +// memPluginRegistry is an in-process pluginRegistry: versions per ID, +// a list, call counters, an in-flight gauge and a failure switch. +type memPluginRegistry struct { + mu sync.Mutex + versions map[string][]kiosk.RegistryVersion + list []kiosk.RegistryPlugin + fail error + hold time.Duration + calls []string + inFlight int + maxFlight int +} + +func (m *memPluginRegistry) List(_ context.Context, q, category string) (kiosk.ListResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, "list "+q+"|"+category) + if m.fail != nil { + return kiosk.ListResponse{}, m.fail + } + var out []kiosk.RegistryPlugin + for _, p := range m.list { + if (category == "" || p.Category == category) && (q == "" || strings.Contains(p.ID, q)) { + out = append(out, p) + } + } + return kiosk.ListResponse{Data: out, Meta: kiosk.ListMeta{Total: len(out)}}, nil +} + +func (m *memPluginRegistry) Versions(ctx context.Context, id string) ([]kiosk.RegistryVersion, error) { + m.mu.Lock() + m.calls = append(m.calls, "versions "+id) + m.inFlight++ + if m.inFlight > m.maxFlight { + m.maxFlight = m.inFlight + } + hold, fail := m.hold, m.fail + v, ok := m.versions[id] + m.mu.Unlock() + defer func() { + m.mu.Lock() + m.inFlight-- + m.mu.Unlock() + }() + if hold > 0 { + select { + case <-time.After(hold): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if fail != nil { + return nil, fail + } + if !ok { + return nil, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) + } + return v, nil +} + +func (m *memPluginRegistry) count(prefix string) int { + m.mu.Lock() + defer m.mu.Unlock() + n := 0 + for _, c := range m.calls { + if strings.HasPrefix(c, prefix) { + n++ + } + } + return n +} + +// testClock is a settable clock for the catalog's cache. +type testClock struct { + mu sync.Mutex + now time.Time +} + +func (c *testClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *testClock) advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +// unitPluginsService is pluginsService with its catalog replaced by one +// over reg (nil: disabled) and clock. +func unitPluginsService(t *testing.T, installed *party.Installed, reg *memPluginRegistry, clock *testClock) *service { + t.Helper() + svc := pluginsService(t, pluginsConfig(t, ""), installed, nil) + catalog := &pluginCatalog{ + timeout: time.Second, + now: clock.Now, + lists: map[string]cachedList{}, + versions: map[string]cachedVersions{}, + } + if reg == nil { + catalog.disabled = true + } else { + catalog.registry = reg + } + svc.catalog = catalog + return svc +} + +func rel(version, framework string) kiosk.RegistryVersion { + return kiosk.RegistryVersion{Version: version, Framework: framework} +} + +func byPluginID(overview PluginsOverview) map[string]InstalledPlugin { + out := map[string]InstalledPlugin{} + for _, p := range overview.Plugins { + out[p.ID] = p + } + return out +} + +// TestPluginsEndpoints covers the Plugins screen endpoints at unit level: +// access, the settings entry, the update rule, the registry cache and +// concurrency bound, and the marketplace query rules. +func TestPluginsEndpoints(t *testing.T) { + clock := &testClock{now: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)} + + t.Run("access", func(t *testing.T) { + svc := unitPluginsService(t, testInstalled(), nil, clock) + notBackend := &bouncer.Principal{ID: 9, Backend: false, IsSuperuser: true} + for _, target := range []string{"/plugins", "/plugins/marketplace"} { + handler := (*service).pluginsInstalled + if target != "/plugins" { + handler = (*service).pluginsMarketplace + } + for _, c := range []struct { + name string + principal *bouncer.Principal + want int + }{ + {"anonymous", nil, http.StatusUnauthorized}, + {"frontend principal", notBackend, http.StatusUnauthorized}, + {"no permission", pluginsDenied, http.StatusForbidden}, + {"permission", pluginsViewer, http.StatusOK}, + {"superuser", pluginsSuperuser, http.StatusOK}, + } { + rec := pluginsCall(svc, handler, c.principal, target) + if rec.Code != c.want { + t.Errorf("%s %s: status %d, want %d (%s)", target, c.name, rec.Code, c.want, rec.Body.String()) + } + } + } + }) + + t.Run("settings entry", func(t *testing.T) { + entry := pluginsSettingsEntry("Plugins", "Installed plugins", "system") + if entry.Path != "/system/plugins" || entry.Code != "plugins" || entry.Controller != "" || entry.Keywords == nil { + t.Errorf("entry = %+v", entry) + } + raw, _ := json.Marshal(SettingsEntry{Code: "other"}) + if strings.Contains(string(raw), `"path"`) { + t.Errorf("an entry without a path serializes one: %s", raw) + } + }) + + t.Run("update rule", func(t *testing.T) { + cases := []struct { + name, installed, framework string + versions []kiosk.RegistryVersion + latest, latestFramework string + update bool + }{ + {"exact tag, newer compatible", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0"), rel("v0.1.0", "v0.5.0")}, "v0.2.0", "", true}, + {"exact tag, current", "v0.2.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0")}, "v0.2.0", "", false}, + {"describe base equal to latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.1", "v0.6.0")}, "v0.1.1", "", false}, + {"describe base below latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0"), rel("v0.1.1", "v0.6.0")}, "v0.1.2", "", true}, + {"untagged commit", "abc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0")}, "v0.1.2", "", false}, + {"newer incompatible only", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0"), rel("v0.1.0", "v0.5.0")}, "v0.1.0", "v0.7.0", false}, + {"compatible update and newer incompatible", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.8.0"), rel("v0.2.0", "v0.6.0")}, "v0.2.0", "v0.8.0", true}, + {"incompatible but not newer than installed", "v0.3.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, "", "", false}, + {"framework is a describe string", "v0.1.0", "v0.6.0-4-gdeadbee", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0")}, "v0.2.0", "", true}, + } + for _, c := range cases { + p := InstalledPlugin{ID: "acme.blog", Version: c.installed} + applyUpdate(&p, c.versions, frameworkRelease(c.framework)) + wantCmd := "" + if c.update { + wantCmd = "summer plugin:update acme.blog" + } + if !p.Listed || p.Latest != c.latest || p.LatestFramework != c.latestFramework || p.UpdateAvailable != c.update || p.UpdateCommand != wantCmd { + t.Errorf("%s: %+v", c.name, p) + } + } + }) + + t.Run("update badges through the endpoint", func(t *testing.T) { + reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{ + "acme.blog": {rel("v0.2.0", "v0.6.0"), rel("v0.1.0", "v0.5.0")}, + "acme.translate": {rel("v0.1.1", "v0.6.0")}, + }} + installed := testInstalled() + installed.Plugins = append(installed.Plugins, party.Info{ID: "acme.unlisted", Version: "v1.0.0", Source: party.SourceModule}) + svc := unitPluginsService(t, installed, reg, clock) + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsViewer, "/plugins")) + if overview.Registry != (RegistryState{Enabled: true, Status: registryStatusOK}) { + t.Errorf("registry = %+v", overview.Registry) + } + got := byPluginID(overview) + if b := got["acme.blog"]; !b.UpdateAvailable || b.Latest != "v0.2.0" || b.UpdateCommand != "summer plugin:update acme.blog" { + t.Errorf("blog = %+v", b) + } + if tr := got["acme.translate"]; tr.UpdateAvailable || tr.Latest != "v0.1.1" || !tr.Listed { + t.Errorf("translate = %+v", tr) + } + if u := got["acme.unlisted"]; u.Listed || u.UpdateAvailable || u.Latest != "" { + t.Errorf("unlisted = %+v", u) + } + if l := got["acme.local"]; l.Listed || l.UpdateAvailable { + t.Errorf("local = %+v", l) + } + if n := reg.count("versions acme.local"); n != 0 { + t.Errorf("the local plugin was looked up %d times", n) + } + }) + + t.Run("registry error keeps the list", func(t *testing.T) { + reg := &memPluginRegistry{fail: errors.New("connection refused")} + svc := unitPluginsService(t, testInstalled(), reg, clock) + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + if overview.Registry != (RegistryState{Enabled: true, Status: registryStatusUnavailable}) || len(overview.Plugins) != 3 { + t.Errorf("overview = %+v", overview) + } + for _, p := range overview.Plugins { + if p.UpdateAvailable || p.Listed { + t.Errorf("plugin with a failed lookup = %+v", p) + } + } + }) + + t.Run("cache hit and expiry", func(t *testing.T) { + reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}, "acme.translate": {rel("v0.1.1", "v0.6.0")}}} + clk := &testClock{now: clock.Now()} + svc := unitPluginsService(t, testInstalled(), reg, clk) + call := func() { decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) } + call() + call() + clk.advance(versionsTTL - time.Second) + call() + if n := reg.count("versions acme.blog"); n != 1 { + t.Fatalf("lookups within the TTL = %d, want 1", n) + } + clk.advance(2 * time.Second) + call() + if n := reg.count("versions acme.blog"); n != 2 { + t.Fatalf("lookups after the TTL = %d, want 2", n) + } + + // A failure is cached for registryFailureTTL only. + reg.mu.Lock() + reg.fail = errors.New("down") + reg.mu.Unlock() + clk.advance(versionsTTL + time.Second) + call() + before := reg.count("versions acme.blog") + clk.advance(registryFailureTTL / 2) + call() + if n := reg.count("versions acme.blog"); n != before { + t.Fatalf("a cached failure was retried within %s", registryFailureTTL) + } + clk.advance(registryFailureTTL) + call() + if n := reg.count("versions acme.blog"); n != before+1 { + t.Fatalf("a failure was not retried after %s", registryFailureTTL) + } + + // Searches are cached per query and category. + reg.mu.Lock() + reg.fail = nil + reg.mu.Unlock() + for i := 0; i < 3; i++ { + pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog") + } + if n := reg.count("list blog|"); n != 1 { + t.Fatalf("search lookups = %d, want 1", n) + } + clk.advance(marketplaceTTL + time.Second) + pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog") + if n := reg.count("list blog|"); n != 2 { + t.Fatalf("search lookups after the TTL = %d, want 2", n) + } + }) + + t.Run("concurrency bound", func(t *testing.T) { + installed := &party.Installed{Framework: "v0.6.0"} + reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{}, hold: 30 * time.Millisecond} + for i := 0; i < 12; i++ { + id := fmt.Sprintf("acme.p%d", i) + installed.Plugins = append(installed.Plugins, party.Info{ID: id, Version: "v0.1.0", Source: party.SourceModule}) + reg.versions[id] = []kiosk.RegistryVersion{rel("v0.1.0", "v0.6.0")} + } + svc := unitPluginsService(t, installed, reg, clock) + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + if overview.Registry.Status != registryStatusOK || reg.count("versions ") != 12 { + t.Fatalf("registry %+v, lookups %d", overview.Registry, reg.count("versions ")) + } + if reg.maxFlight > registryLookups || reg.maxFlight < 2 { + t.Errorf("at most %d lookups ran at once, want between 2 and %d", reg.maxFlight, registryLookups) + } + }) + + t.Run("slow registry times out", func(t *testing.T) { + reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}}, hold: time.Second} + svc := unitPluginsService(t, testInstalled(), reg, clock) + svc.catalog.timeout = 20 * time.Millisecond + start := time.Now() + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + if overview.Registry.Status != registryStatusUnavailable || time.Since(start) > 500*time.Millisecond { + t.Errorf("registry %+v after %s", overview.Registry, time.Since(start)) + } + }) + + t.Run("marketplace", func(t *testing.T) { + reg := &memPluginRegistry{list: []kiosk.RegistryPlugin{ + {ID: "acme.blog", Name: "Blog", Category: "content", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0", IconURL: "https://cdn.example.test/blog.png"}, + {ID: "acme.shop", Name: "Shop", Category: "commerce", Featured: true, LatestVersion: "v1.0.0", LatestFramework: "v0.9.0", IconURL: "http://cdn.example.test/shop.png"}, + {ID: "acme.forum", Name: "Forum", Category: "communication", LatestVersion: "v0.1.0", IconURL: "https://user:pw@cdn.example.test/x.png"}, + {ID: "Acme.Evil;rm", Name: "Evil", Category: "tools"}, + {ID: "acme.odd", Name: "Odd", Category: "Not Valid"}, + }} + svc := unitPluginsService(t, testInstalled(), reg, clock) + rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace") + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + out := decodeMarketplace(t, rec) + if out.Meta.Total != 4 || len(out.Data) != 4 || out.Meta.Registry.Status != registryStatusOK { + t.Fatalf("marketplace = %+v", out) + } + if strings.Join(out.Meta.Categories, ",") != "commerce,communication,content" { + t.Errorf("categories = %v", out.Meta.Categories) + } + got := map[string]MarketplacePlugin{} + for _, p := range out.Data { + got[p.ID] = p + if p.InstallCommand != "summer plugin:install "+p.ID { + t.Errorf("%s command = %q", p.ID, p.InstallCommand) + } + } + if _, ok := got["Acme.Evil;rm"]; ok { + t.Error("an ID outside the plugin ID shape was offered") + } + if b := got["acme.blog"]; !b.Installed || !b.Compatible || b.IconURL != "https://cdn.example.test/blog.png" { + t.Errorf("blog = %+v", b) + } + if s := got["acme.shop"]; s.Installed || s.Compatible || s.IconURL != "" || !s.Featured { + t.Errorf("shop = %+v", s) + } + if f := got["acme.forum"]; !f.Compatible || f.IconURL != "" { + t.Errorf("forum = %+v", f) + } + + // A filtered search still gets every category for the select. + rec = pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?category=content&q=blog") + filtered := decodeMarketplace(t, rec) + if len(filtered.Data) != 1 || filtered.Data[0].ID != "acme.blog" || len(filtered.Meta.Categories) != 3 { + t.Errorf("filtered = %+v", filtered) + } + if n := reg.count("list blog|content"); n != 1 { + t.Errorf("filtered search lookups = %d", n) + } + + for target, field := range map[string]string{ + "/plugins/marketplace?q=" + strings.Repeat("x", 101): "q", + "/plugins/marketplace?category=Content": "category", + "/plugins/marketplace?category=" + strings.Repeat("a", 41): "category", + "/plugins/marketplace?category=" + url.QueryEscape("a b"): "category", + } { + before := reg.count("list ") + rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, target) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_query") || !strings.Contains(rec.Body.String(), `"`+field+`"`) { + t.Errorf("%s: status %d body %s", target, rec.Code, rec.Body.String()) + } + if reg.count("list ") != before { + t.Errorf("%s reached the registry", target) + } + } + if rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q="+strings.Repeat("ż", 100)); rec.Code != http.StatusOK { + t.Errorf("100 runes: status %d", rec.Code) + } + + reg.mu.Lock() + reg.fail = errors.New("down") + reg.mu.Unlock() + rec = pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q=new") + down := decodeMarketplace(t, rec) + if rec.Code != http.StatusOK || down.Meta.Registry.Status != registryStatusUnavailable || len(down.Data) != 0 || down.Data == nil { + t.Errorf("registry down: %d %+v", rec.Code, down) + } + }) + + t.Run("disabled registry makes no call", func(t *testing.T) { + reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}}} + svc := unitPluginsService(t, testInstalled(), reg, clock) + svc.catalog.disabled = true + overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) + rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=x") + market := decodeMarketplace(t, rec) + if overview.Registry != (RegistryState{Enabled: false, Status: registryStatusDisabled}) || market.Meta.Registry.Status != registryStatusDisabled { + t.Errorf("overview %+v, marketplace %+v", overview.Registry, market.Meta) + } + if len(reg.calls) != 0 { + t.Errorf("a disabled registry was called: %v", reg.calls) + } + }) + + t.Run("unusable registry URL", func(t *testing.T) { + c := newPluginCatalog(pluginsConfig(t, "ftp://registry.example.test")) + if !c.enabled() || c.registry != nil { + t.Fatalf("catalog = %+v", c) + } + if _, _, err := c.releases(context.Background(), "acme.blog"); !errors.Is(err, errRegistryUnusable) { + t.Errorf("releases = %v", err) + } + if _, err := c.list(context.Background(), "", ""); !errors.Is(err, errRegistryUnusable) { + t.Errorf("list = %v", err) + } + }) + + t.Run("helpers", func(t *testing.T) { + for in, want := range map[string]string{ + "https://cdn.example.test/a.png": "https://cdn.example.test/a.png", + "http://cdn.example.test/a.png": "", + "//cdn.example.test/a.png": "", + "javascript:alert(1)": "", + "https:///a.png": "", + "": "", + } { + if got := httpsURL(in); got != want { + t.Errorf("httpsURL(%q) = %q", in, got) + } + } + for in, want := range map[string]string{"v0.6.0": "v0.6.0", "v0.6.0-2-gabc1234": "v0.6.0", "devel": "devel", "": ""} { + if got := frameworkRelease(in); got != want { + t.Errorf("frameworkRelease(%q) = %q", in, got) + } + } + got := categoriesOf([]kiosk.RegistryPlugin{{ID: "a.seo", Category: "seo"}, {ID: "a.blog", Category: "content"}, {ID: "b.seo", Category: "seo"}, + {ID: "a.none", Category: ""}, {ID: "a.bad", Category: "Bad Cat"}, {ID: "Bad;id", Category: "tools"}}) + if strings.Join(got, ",") != "content,seo" { + t.Errorf("categoriesOf = %v", got) + } + }) +} diff --git a/scripts/check-phase34.sh b/scripts/check-phase34.sh index d4ea1d7b..b534d0bb 100755 --- a/scripts/check-phase34.sh +++ b/scripts/check-phase34.sh @@ -100,6 +100,7 @@ GO_TESTS=( ./internal/build:TestPluginDirs ./internal/build:TestEffectiveFramework ./internal/build:TestResolveInstall ./internal/build:TestAskToProceed ./internal/build:TestAppendManifestPlugins ./cmd/summer:TestPluginCommands + ./modules/cabana:TestPluginsEndpoints ) # docker stage: the non-short framework tests (file:// proxies, git @@ -113,7 +114,7 @@ SITE_SHORT_TESTS=(.:TestPluginIdentity .:TestRoutesAssemble) SITE_DOCKER_TESTS=(.:TestRegistrySmoke) # admin stage: vitest files that must pass. -ADMIN_FILES=(tests/views/PluginsView.test.ts) +ADMIN_FILES=(tests/views/PluginsView.test.ts tests/state/useSettings.test.ts) # marker stage: names of consuming applications (split so this file does # not match itself). summercms.io, the framework's own site and default