With a framework version that is not a release (a devel build, an empty FrameworkVersion) every plugin with a minimum was marked as needing a newer framework, and Compatible looked only at the newest release, so a plugin whose older release fits was marked although plugin:install would install that release. An unknown framework now gives no marker (and no needs-framework note on the Installed tab), and a plugin whose newest release is too new is looked up (cached, four at a time) and counts as compatible when an older release fits.
557 lines
22 KiB
Go
557 lines
22 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 compatibility", func(t *testing.T) {
|
|
list := []kiosk.RegistryPlugin{
|
|
{ID: "acme.shop", Name: "Shop", LatestVersion: "v1.0.0", LatestFramework: "v0.9.0"},
|
|
{ID: "acme.old", Name: "Old", LatestVersion: "v0.3.0", LatestFramework: "v0.9.0"},
|
|
{ID: "acme.blog", Name: "Blog", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0"},
|
|
}
|
|
versions := map[string][]kiosk.RegistryVersion{
|
|
"acme.shop": {rel("v1.0.0", "v0.9.0"), rel("v0.9.0", "v0.8.0")},
|
|
"acme.old": {rel("v0.3.0", "v0.9.0"), rel("v0.1.0", "v0.5.0")},
|
|
}
|
|
compat := func(t *testing.T, framework string) (map[string]bool, *memPluginRegistry) {
|
|
t.Helper()
|
|
reg := &memPluginRegistry{list: list, versions: versions}
|
|
installed := testInstalled()
|
|
installed.Framework = framework
|
|
svc := unitPluginsService(t, installed, reg, clock)
|
|
got := map[string]bool{}
|
|
for _, p := range decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace")).Data {
|
|
got[p.ID] = p.Compatible
|
|
}
|
|
return got, reg
|
|
}
|
|
// A release framework: an older compatible release counts, as it
|
|
// does for plugin:install; only the incompatible are looked up.
|
|
got, reg := compat(t, "v0.6.0")
|
|
if got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] {
|
|
t.Errorf("v0.6.0: %v", got)
|
|
}
|
|
if reg.count("versions acme.blog") != 0 {
|
|
t.Error("a compatible plugin was looked up")
|
|
}
|
|
// A framework that is not a release cannot be judged: no marker.
|
|
for _, fw := range []string{"(devel)", "", "abc1234"} {
|
|
got, reg := compat(t, fw)
|
|
if !got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] || reg.count("versions") != 0 {
|
|
t.Errorf("%q: %v", fw, got)
|
|
}
|
|
}
|
|
p := InstalledPlugin{ID: "acme.blog", Version: "v0.1.0"}
|
|
applyUpdate(&p, []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, frameworkRelease("(devel)"))
|
|
if p.LatestFramework != "" || p.UpdateAvailable {
|
|
t.Errorf("devel framework marks the installed plugin: %+v", p)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|