Files
summercms/modules/cabana/plugins_test.go
Jakub Zych 7b193032c8 fix(34): WR-06 bound the Plugins screen's search cache
pluginCatalog.lists was keyed by every distinct query and category and
never dropped an expired entry, so searching distinct strings grew the
process without limit (each entry can hold a 4 MiB answer). Storing a
search into a full cache now drops the expired searches and then the
ones that expire first, keeping at most 256.
2026-10-29 22:10:41 +01:00

512 lines
20 KiB
Go

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},
// A module at a commit after v0.4.0 (go get M@main): v0.4.0 is
// no update, v0.4.1 is.
{"pseudo-version after the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.6.0")}, "v0.4.0", "", false},
{"pseudo-version before the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.1", "v0.6.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.1", "", true},
{"pseudo-version without a tag before it", "v0.0.0-20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.0", "v0.6.0")}, "v0.1.0", "", false},
{"pseudo-version, newer incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.5.0", "v0.7.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.0", "v0.7.0", false},
{"pseudo-version, older incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.7.0")}, "", "", 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("search cache is bounded", func(t *testing.T) {
reg := &memPluginRegistry{}
clk := &testClock{now: clock.Now()}
c := &pluginCatalog{registry: reg, timeout: time.Second, now: clk.Now, lists: map[string]cachedList{}, versions: map[string]cachedVersions{}}
for i := 0; i < marketplaceCacheSize+50; i++ {
clk.advance(time.Millisecond)
if _, err := c.list(t.Context(), fmt.Sprintf("q%d", i), ""); err != nil {
t.Fatal(err)
}
}
if n := len(c.lists); n != marketplaceCacheSize {
t.Fatalf("cached searches = %d, want at most %d", n, marketplaceCacheSize)
}
// The oldest went first; the newest is still a hit.
if _, ok := c.lists["q0\x00"]; ok {
t.Error("the oldest search survived")
}
before := reg.count(fmt.Sprintf("list q%d|", marketplaceCacheSize+49))
if _, err := c.list(t.Context(), fmt.Sprintf("q%d", marketplaceCacheSize+49), ""); err != nil || reg.count(fmt.Sprintf("list q%d|", marketplaceCacheSize+49)) != before {
t.Errorf("the newest search was not cached: %v", err)
}
// Expired searches are dropped when a new one needs the room.
clk.advance(marketplaceTTL + time.Second)
if _, err := c.list(t.Context(), "fresh", ""); err != nil {
t.Fatal(err)
}
if n := len(c.lists); n != 1 {
t.Errorf("cached searches after expiry = %d, want 1", n)
}
})
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)
}
})
}