- GET /plugins looks up each versioned plugin's releases through kiosk.Client and applies plugin:update's rule (NewestCompatible above the BaseVersion), with the update command and the framework a newer release needs - GET /plugins/marketplace forwards q and category (validated like the registry), marks installed and incompatible plugins and returns the install command; ids outside the plugin ID shape are dropped - 4 s per-call timeout, at most 4 lookups at once, in-process cache (searches 5 min, releases 15 min, failures 1 min); a failing registry gives status unavailable, an empty plugins.registry_url sends nothing - SPA: update badges and Marketplace tab with copy buttons (clipboard fallback), hidden when the registry is disabled; no install button - OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
483 lines
20 KiB
Go
483 lines
20 KiB
Go
package cabana
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/kiosk"
|
|
"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, catalog: newPluginCatalog(cfg)}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
|
|
pluginsRegistrySubtests(t)
|
|
}
|
|
|
|
// fakeRegistry is a registry API v1 server: versions per plugin ID, the
|
|
// plugin list, a request log, and switches for failures and slowness.
|
|
type fakeRegistry struct {
|
|
srv *httptest.Server
|
|
mu sync.Mutex
|
|
requests []string
|
|
versions map[string][]kiosk.RegistryVersion
|
|
list []kiosk.RegistryPlugin
|
|
fail atomic.Bool
|
|
delay atomic.Int64
|
|
}
|
|
|
|
func newFakeRegistry(t *testing.T) *fakeRegistry {
|
|
t.Helper()
|
|
f := &fakeRegistry{versions: map[string][]kiosk.RegistryVersion{}}
|
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
f.requests = append(f.requests, r.URL.RequestURI())
|
|
f.mu.Unlock()
|
|
if d := time.Duration(f.delay.Load()); d > 0 {
|
|
select {
|
|
case <-time.After(d):
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
if f.fail.Load() {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, kiosk.RegistryAPIPath)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case path == "/plugins":
|
|
out := []kiosk.RegistryPlugin{}
|
|
q, category := r.URL.Query().Get("q"), r.URL.Query().Get("category")
|
|
for _, p := range f.list {
|
|
if (category == "" || p.Category == category) && (q == "" || strings.Contains(p.ID+" "+p.Name, q)) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(kiosk.ListResponse{Data: out, Meta: kiosk.ListMeta{Total: len(out)}})
|
|
case strings.HasPrefix(path, "/plugins/") && strings.HasSuffix(path, "/versions"):
|
|
id := strings.TrimSuffix(strings.TrimPrefix(path, "/plugins/"), "/versions")
|
|
versions, ok := f.versions[id]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: "no such plugin"}})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: versions})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
// log returns the request URIs so far and clears the log.
|
|
func (f *fakeRegistry) log() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.requests
|
|
f.requests = nil
|
|
return out
|
|
}
|
|
|
|
// registryFixture lists acme.blog (v0.1.1 for this framework, v0.2.0 for
|
|
// a newer one), acme.translate (newest compatible v0.1.1) and a
|
|
// marketplace of three plugins.
|
|
func registryFixture(t *testing.T) *fakeRegistry {
|
|
t.Helper()
|
|
f := newFakeRegistry(t)
|
|
f.versions["acme.blog"] = []kiosk.RegistryVersion{
|
|
{Version: "v0.2.0", Framework: "v0.7.0"},
|
|
{Version: "v0.1.1", Framework: "v0.6.0"},
|
|
{Version: "v0.1.0", Framework: "v0.5.0"},
|
|
}
|
|
f.versions["acme.translate"] = []kiosk.RegistryVersion{
|
|
{Version: "v0.1.1", Framework: "v0.6.0"},
|
|
{Version: "v0.1.0", Framework: "v0.6.0"},
|
|
}
|
|
f.list = []kiosk.RegistryPlugin{
|
|
{ID: "acme.blog", Name: "Blog", Description: "Posts.", Category: "content", LatestVersion: "v0.2.0", LatestFramework: "v0.7.0"},
|
|
{ID: "acme.journal", Name: "Journal", Description: "A <b>journal</b>.", Category: "content", Featured: true, IconURL: "https://registry.example.test/icon.png", LatestVersion: "v0.3.0", LatestFramework: "v0.6.0"},
|
|
{ID: "acme.shop", Name: "Shop", Description: "Orders.", Category: "commerce", LatestVersion: "v1.0.0", LatestFramework: "v0.5.0"},
|
|
}
|
|
return f
|
|
}
|
|
|
|
func pluginByID(t *testing.T, overview PluginsOverview, id string) InstalledPlugin {
|
|
t.Helper()
|
|
for _, p := range overview.Plugins {
|
|
if p.ID == id {
|
|
return p
|
|
}
|
|
}
|
|
t.Fatalf("plugin %s missing from %+v", id, overview.Plugins)
|
|
return InstalledPlugin{}
|
|
}
|
|
|
|
func decodeMarketplace(t *testing.T, rec *httptest.ResponseRecorder) MarketplaceResponse {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body MarketplaceResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// pluginsRegistrySubtests are TestPluginsScreenSmoke's update badge and
|
|
// marketplace subtests, run against a fake registry.
|
|
func pluginsRegistrySubtests(t *testing.T) {
|
|
t.Run("updates", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if !overview.Registry.Enabled || overview.Registry.Status != registryStatusOK {
|
|
t.Fatalf("registry = %+v", overview.Registry)
|
|
}
|
|
blog := pluginByID(t, overview, "acme.blog")
|
|
if !blog.Listed || blog.Latest != "v0.1.1" || !blog.UpdateAvailable || blog.UpdateCommand != "summer plugin:update acme.blog" || blog.LatestFramework != "v0.7.0" {
|
|
t.Fatalf("blog = %+v", blog)
|
|
}
|
|
})
|
|
|
|
t.Run("describe_base", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
translate := pluginByID(t, overview, "acme.translate")
|
|
if !translate.Listed || translate.Latest != "v0.1.1" || translate.UpdateAvailable || translate.UpdateCommand != "" || translate.LatestFramework != "" {
|
|
t.Fatalf("translate at v0.1.1-3-gabc1234 = %+v", translate)
|
|
}
|
|
})
|
|
|
|
t.Run("unlisted", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
delete(f.versions, "acme.translate")
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
translate := pluginByID(t, overview, "acme.translate")
|
|
if translate.Listed || translate.Latest != "" || translate.UpdateAvailable || translate.UpdateCommand != "" {
|
|
t.Fatalf("unlisted translate = %+v", translate)
|
|
}
|
|
if overview.Registry.Status != registryStatusOK {
|
|
t.Fatalf("a 404 for one plugin made the registry %+v", overview.Registry)
|
|
}
|
|
// The local plugin (no version) is never looked up.
|
|
for _, uri := range f.log() {
|
|
if strings.Contains(uri, "acme.local") {
|
|
t.Fatalf("local plugin looked up: %s", uri)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("unavailable", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
f.fail.Store(true)
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if !overview.Registry.Enabled || overview.Registry.Status != registryStatusUnavailable || len(overview.Plugins) != 3 {
|
|
t.Fatalf("500 registry overview = %+v", overview)
|
|
}
|
|
if blog := pluginByID(t, overview, "acme.blog"); blog.Version != "v0.1.0" || blog.UpdateAvailable || blog.Listed {
|
|
t.Fatalf("blog with the registry down = %+v", blog)
|
|
}
|
|
market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=journal"))
|
|
if market.Meta.Registry.Status != registryStatusUnavailable || len(market.Data) != 0 {
|
|
t.Fatalf("500 registry marketplace = %+v", market)
|
|
}
|
|
|
|
// A registry slower than the call timeout is unavailable too.
|
|
slow := registryFixture(t)
|
|
slow.delay.Store(int64(2 * time.Second))
|
|
svc = pluginsService(t, pluginsConfig(t, slow.srv.URL), testInstalled(), nil)
|
|
svc.pluginCatalog().timeout = 50 * time.Millisecond
|
|
start := time.Now()
|
|
overview = decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if overview.Registry.Status != registryStatusUnavailable || len(overview.Plugins) != 3 {
|
|
t.Fatalf("slow registry overview = %+v", overview)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > time.Second {
|
|
t.Fatalf("slow registry held the answer for %v", elapsed)
|
|
}
|
|
})
|
|
|
|
t.Run("disabled", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
svc := pluginsService(t, pluginsConfig(t, ""), testInstalled(), nil)
|
|
overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
if overview.Registry.Enabled || overview.Registry.Status != registryStatusDisabled {
|
|
t.Fatalf("disabled registry = %+v", overview.Registry)
|
|
}
|
|
for _, p := range overview.Plugins {
|
|
if p.Listed || p.UpdateAvailable || p.Latest != "" {
|
|
t.Fatalf("disabled registry still reported %+v", p)
|
|
}
|
|
}
|
|
market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace"))
|
|
if market.Meta.Registry.Enabled || market.Meta.Registry.Status != registryStatusDisabled || len(market.Data) != 0 {
|
|
t.Fatalf("disabled marketplace = %+v", market)
|
|
}
|
|
if got := f.log(); len(got) != 0 {
|
|
t.Fatalf("requests with the registry disabled: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("marketplace", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q=journal&category=content"))
|
|
if market.Meta.Registry.Status != registryStatusOK || len(market.Data) != 1 {
|
|
t.Fatalf("marketplace = %+v", market)
|
|
}
|
|
journal := market.Data[0]
|
|
if journal.ID != "acme.journal" || journal.Name != "Journal" || journal.Description != "A <b>journal</b>." || !journal.Featured ||
|
|
journal.LatestVersion != "v0.3.0" || journal.Installed || !journal.Compatible || journal.InstallCommand != "summer plugin:install acme.journal" ||
|
|
journal.IconURL != "https://registry.example.test/icon.png" || journal.Category != "content" {
|
|
t.Fatalf("journal = %+v", journal)
|
|
}
|
|
forwarded := false
|
|
for _, uri := range f.log() {
|
|
if strings.Contains(uri, "q=journal") && strings.Contains(uri, "category=content") {
|
|
forwarded = true
|
|
}
|
|
}
|
|
if !forwarded {
|
|
t.Fatal("q and category were not forwarded to the registry")
|
|
}
|
|
if strings.Join(market.Meta.Categories, ",") != "commerce,content" {
|
|
t.Fatalf("categories = %v", market.Meta.Categories)
|
|
}
|
|
|
|
all := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace"))
|
|
if len(all.Data) != 3 || all.Meta.Total != 3 {
|
|
t.Fatalf("unfiltered marketplace = %+v", all)
|
|
}
|
|
blog := all.Data[0]
|
|
if blog.ID != "acme.blog" || !blog.Installed || blog.Compatible || blog.LatestFramework != "v0.7.0" {
|
|
t.Fatalf("installed blog needing a newer framework = %+v", blog)
|
|
}
|
|
|
|
for _, target := range []string{
|
|
"/plugins/marketplace?q=" + strings.Repeat("a", 101),
|
|
"/plugins/marketplace?category=Content",
|
|
"/plugins/marketplace?category=" + strings.Repeat("a", 41),
|
|
} {
|
|
rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, target)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_query"`) {
|
|
t.Fatalf("%s: status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsDenied, "/plugins/marketplace"); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("marketplace without %s: status=%d", PermissionViewPlugins, rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("cache", func(t *testing.T) {
|
|
f := registryFixture(t)
|
|
svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
|
|
now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
|
|
svc.pluginCatalog().now = func() time.Time { return now }
|
|
|
|
decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
|
|
if first := f.log(); len(first) == 0 {
|
|
t.Fatal("no registry request on the first load")
|
|
}
|
|
decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
|
|
if again := f.log(); len(again) != 0 {
|
|
t.Fatalf("cached answers still called the registry: %v", again)
|
|
}
|
|
|
|
// After the marketplace window (5 minutes) the search is asked
|
|
// again; the versions (15 minutes) are not.
|
|
now = now.Add(6 * time.Minute)
|
|
decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
|
|
decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
|
|
for _, uri := range f.log() {
|
|
if strings.Contains(uri, "/versions") {
|
|
t.Fatalf("versions asked again inside their window: %s", uri)
|
|
}
|
|
}
|
|
|
|
// A failure is cached for a minute only.
|
|
f.fail.Store(true)
|
|
now = now.Add(16 * time.Minute)
|
|
if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusUnavailable {
|
|
t.Fatalf("failing registry = %+v", got.Registry)
|
|
}
|
|
f.fail.Store(false)
|
|
f.log()
|
|
if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusUnavailable {
|
|
t.Fatalf("failure not cached: %+v", got.Registry)
|
|
}
|
|
if got := f.log(); len(got) != 0 {
|
|
t.Fatalf("cached failure still called the registry: %v", got)
|
|
}
|
|
now = now.Add(2 * time.Minute)
|
|
if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusOK {
|
|
t.Fatalf("recovered registry = %+v", got.Registry)
|
|
}
|
|
})
|
|
}
|