Files
summercms/modules/kiosk/client_test.go
Jakub Zych 9109e5b139 test(kiosk): manifest, identity, version and client unit tests
- TestReadIdentity: literal, const, chained, cross-package, alias,
  other-module and Requires shapes; refusals naming ID()/Requires()
- TestDerive, TestManifestFormat, TestCheck, TestVersions,
  TestRegistryClient (httptest: escaping, 404, envelopes, cap, redirect)
- runnable ExampleMarshal and ExampleNewestCompatible
- party TestDescribe: copy of the plugin list, publish once per app
2026-10-29 21:22:47 +01:00

262 lines
9.6 KiB
Go

package kiosk
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/compass"
)
// registryServer serves handler under RegistryAPIPath and records the
// request URIs it saw.
func registryServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.URL.RequestURI())
mu.Unlock()
handler(w, r)
}))
t.Cleanup(srv.Close)
return srv, &seen
}
func newTestClient(t *testing.T, base string) *Client {
t.Helper()
c, err := NewClient(base, nil)
if err != nil {
t.Fatalf("NewClient(%s): %v", base, err)
}
return c
}
// TestRegistryClient covers decoding, query escaping, the error mapping
// and the transport limits of kiosk.Client.
func TestRegistryClient(t *testing.T) {
ctx := context.Background()
t.Run("decode", func(t *testing.T) {
srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q", r.Header.Get("Accept"))
}
switch r.URL.Path {
case RegistryAPIPath + "/plugins":
_ = json.NewEncoder(w).Encode(ListResponse{Data: []RegistryPlugin{{ID: "acme.shop", LatestVersion: "v0.2.0"}}, Meta: ListMeta{Total: 1}})
case RegistryAPIPath + "/plugins/acme.shop":
_ = json.NewEncoder(w).Encode(DetailResponse{Data: RegistryPluginDetail{RegistryPlugin: RegistryPlugin{ID: "acme.shop"}, Requires: Requires{"acme.user": "v0.1.0"}}})
case RegistryAPIPath + "/plugins/acme.shop/versions":
_, _ = w.Write([]byte(`{"data":[{"version":"v0.2.0","framework":"v0.6.0","requires":{},"future":true},{"version":"v0.1.0","framework":"v0.5.0","requires":{"acme.user":""}}]}`))
default:
http.NotFound(w, r)
}
})
c := newTestClient(t, srv.URL+"/")
if c.BaseURL() != srv.URL {
t.Errorf("BaseURL = %q, want the trailing slash trimmed", c.BaseURL())
}
list, err := c.List(ctx, "", "")
if err != nil || len(list.Data) != 1 || list.Data[0].ID != "acme.shop" || list.Meta.Total != 1 {
t.Fatalf("List = %+v, %v", list, err)
}
detail, err := c.Plugin(ctx, "acme.shop")
if err != nil || detail.ID != "acme.shop" || detail.Requires["acme.user"] != "v0.1.0" {
t.Fatalf("Plugin = %+v, %v", detail, err)
}
versions, err := c.Versions(ctx, "acme.shop")
if err != nil || len(versions) != 2 || versions[0].Version != "v0.2.0" || versions[1].Framework != "v0.5.0" {
t.Fatalf("Versions = %+v, %v", versions, err)
}
if (*seen)[0] != RegistryAPIPath+"/plugins" {
t.Errorf("List without filters asked %q", (*seen)[0])
}
})
t.Run("query escaping", func(t *testing.T) {
var got []string
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.Path+"|"+r.URL.Query().Get("q")+"|"+r.URL.Query().Get("category")+"|"+r.URL.RawQuery)
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`))
})
c := newTestClient(t, srv.URL)
if _, err := c.List(ctx, "100% a&b=c #x", "content"); err != nil {
t.Fatal(err)
}
if _, err := c.List(ctx, "", "seo"); err != nil {
t.Fatal(err)
}
if got[0] != RegistryAPIPath+"/plugins|100% a&b=c #x|content|category=content&q=100%25+a%26b%3Dc+%23x" {
t.Errorf("search request = %q", got[0])
}
if got[1] != RegistryAPIPath+"/plugins||seo|category=seo" {
t.Errorf("category request = %q", got[1])
}
})
t.Run("plugin id is path-escaped", func(t *testing.T) {
srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
c := newTestClient(t, srv.URL)
_, _ = c.Plugin(ctx, "a/../b?x")
if len(*seen) != 1 || (*seen)[0] != RegistryAPIPath+"/plugins/a%2F..%2Fb%3Fx" {
t.Errorf("requested %q", *seen)
}
})
t.Run("not found", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no plugin"}}`))
})
c := newTestClient(t, srv.URL)
_, err := c.Plugin(ctx, "acme.gone")
if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "acme.gone") || !strings.Contains(err.Error(), srv.URL) {
t.Errorf("Plugin 404 = %v, want ErrNotFound naming the ID and the registry", err)
}
_, err = c.Versions(ctx, "acme.gone")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Versions 404 = %v", err)
}
_, err = c.List(ctx, "", "")
if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), srv.URL) {
t.Errorf("List 404 = %v", err)
}
})
t.Run("error envelope", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") == "plain" {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html>bad gateway</html>"))
return
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_query","message":"category must match ^[a-z0-9-]{1,40}$"}}`))
})
c := newTestClient(t, srv.URL)
_, err := c.List(ctx, "", "Bad")
if err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "HTTP 400 invalid_query") || !strings.Contains(err.Error(), "category must match") {
t.Errorf("400 = %v, want the code and message", err)
}
_, err = c.List(ctx, "plain", "")
if err == nil || !strings.Contains(err.Error(), "HTTP 502") || strings.Contains(err.Error(), "html") {
t.Errorf("502 without an envelope = %v", err)
}
})
t.Run("server error with envelope", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal","message":"database down"}}`))
})
_, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop")
if err == nil || !strings.Contains(err.Error(), "HTTP 500 internal: database down") {
t.Errorf("500 = %v", err)
}
})
t.Run("bad json", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[`))
})
_, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop")
if err == nil || !strings.Contains(err.Error(), "decode") {
t.Errorf("truncated JSON = %v", err)
}
})
t.Run("oversized body", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0},"pad":"`))
_, _ = w.Write([]byte(strings.Repeat("x", registryMaxBytes)))
_, _ = w.Write([]byte(`"}`))
})
if _, err := newTestClient(t, srv.URL).List(ctx, "", ""); err == nil {
t.Error("a body over the cap was accepted")
}
})
t.Run("redirect is not followed", func(t *testing.T) {
target, targetSeen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`))
})
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.RequestURI(), http.StatusFound)
})
_, err := newTestClient(t, srv.URL).List(ctx, "", "")
if err == nil || !strings.Contains(err.Error(), "redirect") || !strings.Contains(err.Error(), "plugins.registry_url") {
t.Errorf("redirect = %v, want an error naming plugins.registry_url", err)
}
if len(*targetSeen) != 0 {
t.Errorf("the redirect target was called: %v", *targetSeen)
}
})
t.Run("NewClient", func(t *testing.T) {
for _, base := range []string{"", " ", "/"} {
if _, err := NewClient(base, nil); !errors.Is(err, ErrRegistryDisabled) {
t.Errorf("NewClient(%q) = %v, want ErrRegistryDisabled", base, err)
}
}
for base, want := range map[string]string{
"ftp://example.test": "https://",
"example.test": "https://",
"https://": "no host",
"https://user:pw@example.test": "user info",
"https://example.test/?a=b": "query",
"https://example.test/#x": "query",
"https://example.test/?": "query",
"https://exa mple.test": "registry URL",
} {
if _, err := NewClient(base, nil); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("NewClient(%q) = %v, want an error naming %q", base, err, want)
}
}
c, err := NewClient(" https://registry.example.test/ ", nil)
if err != nil || c.BaseURL() != "https://registry.example.test" {
t.Errorf("NewClient trims: %v, %v", c, err)
}
})
t.Run("RegistryURL", func(t *testing.T) {
if got := RegistryURL(nil); got != DefaultRegistryURL {
t.Errorf("RegistryURL(nil) = %q", got)
}
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
if got := RegistryURL(cfg); got != DefaultRegistryURL {
t.Errorf("RegistryURL without the key = %q", got)
}
for set, want := range map[string]string{
"https://registry.example.test/": "https://registry.example.test",
" http://127.0.0.1:8080 ": "http://127.0.0.1:8080",
"": "",
} {
if err := cfg.Set("plugins.registry_url", set); err != nil {
t.Fatal(err)
}
if got := RegistryURL(cfg); got != want {
t.Errorf("RegistryURL(%q) = %q, want %q", set, got, want)
}
}
env, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_PLUGINS__REGISTRY_URL=https://env.example.test"}})
if err != nil {
t.Fatal(err)
}
if got := RegistryURL(env); got != "https://env.example.test" {
t.Errorf("RegistryURL from the environment = %q", got)
}
})
}