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
This commit is contained in:
261
modules/kiosk/client_test.go
Normal file
261
modules/kiosk/client_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
252
modules/kiosk/derive_test.go
Normal file
252
modules/kiosk/derive_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package kiosk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// derivePlugin writes a literal-ID plugin acme.shop with the given go.mod
|
||||
// framework line and README into a temp dir and returns the dir.
|
||||
func derivePlugin(t *testing.T, require, readme string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
gomod := "module example.test/sm-shop-plugin\n\ngo 1.27.0\n"
|
||||
if require != "" {
|
||||
gomod += "\nrequire " + FrameworkModule + " " + require + "\n"
|
||||
}
|
||||
files := map[string]string{
|
||||
"go.mod": gomod,
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" }`,
|
||||
}
|
||||
if readme != "" {
|
||||
files["README.md"] = readme
|
||||
}
|
||||
writeTree(t, dir, files)
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestDerive covers README extraction, overrides, framework rules and
|
||||
// requirement minimums.
|
||||
func TestDerive(t *testing.T) {
|
||||
requireGo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("readme text is flattened", func(t *testing.T) {
|
||||
readme := "<!-- badge -->\n\n# The `acme` *Shop*\n\n" +
|
||||
"Products, [carts](https://example.test/carts) and **orders**\n" +
|
||||
"for `acme` apps, see <https://example.test>.\n\n" +
|
||||
"Second paragraph.\n"
|
||||
m, warnings, err := Derive(ctx, derivePlugin(t, "v0.6.0", readme), DeriveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Derive: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("warnings = %v", warnings)
|
||||
}
|
||||
if m.Name != "The acme Shop" {
|
||||
t.Errorf("name = %q", m.Name)
|
||||
}
|
||||
if want := "Products, carts and orders for acme apps, see https://example.test."; m.Description != want {
|
||||
t.Errorf("description = %q, want %q", m.Description, want)
|
||||
}
|
||||
if m.Package != m.Module || m.Module != "example.test/sm-shop-plugin" || m.Framework != "v0.6.0" || m.Schema != SchemaVersion {
|
||||
t.Errorf("manifest = %+v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readme errors", func(t *testing.T) {
|
||||
cases := []struct{ name, readme, want string }{
|
||||
{"missing", "", "needs a README.md"},
|
||||
{"no h1", "## Shop\n\nText.\n", "has no H1"},
|
||||
{"empty h1", "#\n\nText.\n", "H1"},
|
||||
{"no summary", "# Shop\n\n## Usage\n\nText.\n", "no summary paragraph"},
|
||||
{"id as h1", "# acme.shop\n\nText.\n", "--name"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, err := Derive(ctx, derivePlugin(t, "v0.6.0", c.readme), DeriveOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: err = %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("framework rules", func(t *testing.T) {
|
||||
readme := "# Shop\n\nA shop.\n"
|
||||
if _, _, err := Derive(ctx, derivePlugin(t, "", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "does not require "+FrameworkModule) {
|
||||
t.Errorf("no framework require: err = %v", err)
|
||||
}
|
||||
if _, _, err := Derive(ctx, derivePlugin(t, "v0.0.0", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") {
|
||||
t.Errorf("framework v0.0.0: err = %v", err)
|
||||
}
|
||||
pseudo := "v0.6.1-0.20261029101500-abcdefabcdef"
|
||||
m, warnings, err := Derive(ctx, derivePlugin(t, pseudo, readme), DeriveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("pseudo framework: %v", err)
|
||||
}
|
||||
if m.Framework != pseudo || len(warnings) != 1 || !strings.Contains(warnings[0], "pseudo-version") {
|
||||
t.Errorf("pseudo framework: framework %q, warnings %v", m.Framework, warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overrides", func(t *testing.T) {
|
||||
dir := derivePlugin(t, "v0.6.0", "# acme.shop\n\nA shop.\n")
|
||||
set := func(name, desc *string) (Manifest, error) {
|
||||
t.Helper()
|
||||
if _, _, err := Write(ctx, dir, DeriveOptions{Name: name, Description: desc}); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, FileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Parse(raw)
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
m, err := set(str(" Shop "), str("Products and orders."))
|
||||
if err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if m.Name != "Shop" || m.Description != "Products and orders." || m.Overrides == nil || m.Overrides.Name != "Shop" {
|
||||
t.Fatalf("after set = %+v / %+v", m, m.Overrides)
|
||||
}
|
||||
if m, err = set(nil, nil); err != nil || m.Name != "Shop" || m.Description != "Products and orders." {
|
||||
t.Fatalf("keep: %+v, %v", m, err)
|
||||
}
|
||||
if m, err = set(nil, str("")); err != nil || m.Description != "A shop." || m.Overrides.Description != "" || m.Overrides.Name != "Shop" {
|
||||
t.Fatalf("clear description: %+v / %+v, %v", m, m.Overrides, err)
|
||||
}
|
||||
if _, err = set(str(""), nil); err == nil || !strings.Contains(err.Error(), "--name") {
|
||||
t.Fatalf("clearing the name while the H1 is the ID: err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("corrupt plugin.yaml", func(t *testing.T) {
|
||||
dir := derivePlugin(t, "v0.6.0", "# Shop\n\nA shop.\n")
|
||||
writeTree(t, dir, map[string]string{FileName: "schema: [\n"})
|
||||
if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "delete") {
|
||||
t.Errorf("err = %v, want the advice to delete and rerun", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not a plugin directory", func(t *testing.T) {
|
||||
if _, _, err := Derive(ctx, filepath.Join(t.TempDir(), "x"), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a plugin directory") {
|
||||
t.Errorf("missing dir: err = %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
writeTree(t, dir, map[string]string{"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.x" }`})
|
||||
if _, err := moduleRoot(dir); err == nil {
|
||||
t.Skip("a go.mod above the temp dir")
|
||||
}
|
||||
if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "no go.mod") {
|
||||
t.Errorf("no go.mod: err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("minimums", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fw := "\nrequire " + FrameworkModule + " v0.6.0\n\nreplace " + FrameworkModule + " => ../framework\n"
|
||||
writeTree(t, root, map[string]string{
|
||||
"framework/go.mod": "module " + FrameworkModule + "\n\ngo 1.27.0\n",
|
||||
// A module that requires the framework and holds acme.user.
|
||||
"user/go.mod": "module example.test/sm-user-plugin\n\ngo 1.27.0\n" + fw,
|
||||
"user/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.user" }`,
|
||||
"user/README.md": "# User\n\nUsers.\n",
|
||||
// A module that does not require the framework: never searched.
|
||||
"lib/go.mod": "module example.test/lib\n\ngo 1.27.0\n",
|
||||
"lib/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.lib" }`,
|
||||
"shop/go.mod": "module example.test/sm-shop-plugin\n\ngo 1.27.0\n\nrequire (\n\t" + FrameworkModule + " v0.6.0\n\texample.test/sm-user-plugin v0.1.2\n\texample.test/lib v1.0.0\n)\n\n" +
|
||||
"replace " + FrameworkModule + " => ../framework\n\nreplace example.test/sm-user-plugin => ../user\n\nreplace example.test/lib => ../lib\n",
|
||||
"shop/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" }
|
||||
func (*Plugin) Requires() []string { return []string{"acme.user", "acme.shopadmin", "acme.lib", "acme.soft"} }`,
|
||||
"shop/README.md": "# Shop\n\nA shop.\n",
|
||||
"shop/admin/plugin.go": "package admin\n\ntype Plugin struct{}\n\nfunc (*Plugin) ID() string { return \"acme.shopadmin\" }\n",
|
||||
"shop/admin/README.md": "# Shop admin\n\nAdmin.\n",
|
||||
"shop/testdata/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.soft" }`,
|
||||
})
|
||||
m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Derive: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("warnings = %v", warnings)
|
||||
}
|
||||
want := Requires{"acme.user": "v0.1.2", "acme.shopadmin": "", "acme.lib": "", "acme.soft": ""}
|
||||
if len(m.Requires) != len(want) {
|
||||
t.Fatalf("requires = %v, want %v", m.Requires, want)
|
||||
}
|
||||
for id, v := range want {
|
||||
if got, ok := m.Requires[id]; !ok || got != v {
|
||||
t.Errorf("requires[%s] = %q (present %v), want %q", id, got, ok, v)
|
||||
}
|
||||
}
|
||||
|
||||
nested, _, err := Derive(ctx, filepath.Join(root, "shop", "admin"), DeriveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Derive nested: %v", err)
|
||||
}
|
||||
if nested.Package != "example.test/sm-shop-plugin/admin" || nested.Module != "example.test/sm-shop-plugin" {
|
||||
t.Errorf("nested = %+v", nested)
|
||||
}
|
||||
|
||||
// A required plugin module at v0.0.0 is refused, a pseudo-version warns.
|
||||
shopMod := filepath.Join(root, "shop", "go.mod")
|
||||
orig, _ := os.ReadFile(shopMod)
|
||||
for v, want := range map[string]string{"v0.0.0": "not a released version", "v0.1.3-0.20261029101500-abcdefabcdef": ""} {
|
||||
data := strings.Replace(string(orig), "example.test/sm-user-plugin v0.1.2", "example.test/sm-user-plugin "+v, 1)
|
||||
if err := os.WriteFile(shopMod, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{})
|
||||
if want != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.user") {
|
||||
t.Errorf("%s: err = %v", v, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || m.Requires["acme.user"] != v || len(warnings) != 1 || !strings.Contains(warnings[0], "acme.user") {
|
||||
t.Errorf("%s: requires %v, warnings %v, err %v", v, m.Requires, warnings, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackagePath(t *testing.T) {
|
||||
root := filepath.FromSlash("/src/m")
|
||||
for dir, want := range map[string]string{
|
||||
"/src/m": "example.test/m",
|
||||
"/src/m/a": "example.test/m/a",
|
||||
"/src/m/a/b/c": "example.test/m/a/b/c",
|
||||
} {
|
||||
got, err := packagePath("example.test/m", root, filepath.FromSlash(dir))
|
||||
if err != nil || got != want {
|
||||
t.Errorf("packagePath(%s) = %q, %v; want %q", dir, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDirs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTree(t, root, map[string]string{
|
||||
"plugin.go": "package p",
|
||||
"sub/plugin.go": "package sub",
|
||||
"sub/deeper/plugin.go": "package deeper",
|
||||
"testdata/plugin.go": "package x",
|
||||
"vendor/x/plugin.go": "package x",
|
||||
"node_modules/plugin.go": "package x",
|
||||
".hidden/plugin.go": "package x",
|
||||
"_skip/plugin.go": "package x",
|
||||
"nested/go.mod": "module example.test/nested\n",
|
||||
"nested/plugin.go": "package nested",
|
||||
"other/notplugin.go": "package other",
|
||||
})
|
||||
got := pluginDirs(root)
|
||||
slices.Sort(got)
|
||||
want := []string{root, filepath.Join(root, "sub"), filepath.Join(root, "sub", "deeper")}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("pluginDirs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
57
modules/kiosk/example_test.go
Normal file
57
modules/kiosk/example_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package kiosk_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||
)
|
||||
|
||||
// Marshal writes the plugin.yaml bytes: the generated header, the fields
|
||||
// in a fixed order and the required plugins sorted by ID.
|
||||
func ExampleMarshal() {
|
||||
out, err := kiosk.Marshal(kiosk.Manifest{
|
||||
Schema: kiosk.SchemaVersion,
|
||||
ID: "acme.shop",
|
||||
Module: "example.test/sm-shop-plugin",
|
||||
Package: "example.test/sm-shop-plugin",
|
||||
Name: "Shop",
|
||||
Description: "Products, carts and orders.",
|
||||
Framework: "v0.6.0",
|
||||
Requires: kiosk.Requires{"acme.user": "v0.1.2", "acme.payment": ""},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(string(out))
|
||||
// Output:
|
||||
// # Generated by summer plugin:manifest. Do not edit; rerun the command.
|
||||
// schema: 1
|
||||
// id: acme.shop
|
||||
// module: example.test/sm-shop-plugin
|
||||
// package: example.test/sm-shop-plugin
|
||||
// name: Shop
|
||||
// description: Products, carts and orders.
|
||||
// framework: v0.6.0
|
||||
// requires:
|
||||
// acme.payment: ""
|
||||
// acme.user: v0.1.2
|
||||
}
|
||||
|
||||
// NewestCompatible picks the newest release whose framework minimum the
|
||||
// application's framework meets; the list order does not matter.
|
||||
func ExampleNewestCompatible() {
|
||||
versions := []kiosk.RegistryVersion{
|
||||
{Version: "v0.3.0", Framework: "v0.7.0"},
|
||||
{Version: "v0.2.1", Framework: "v0.6.0"},
|
||||
{Version: "v0.1.0", Framework: "v0.5.0"},
|
||||
}
|
||||
for _, framework := range []string{"v0.6.2", "v0.7.0", "v0.4.0"} {
|
||||
v, ok := kiosk.NewestCompatible(versions, framework)
|
||||
fmt.Println(framework, v.Version, ok)
|
||||
}
|
||||
// Output:
|
||||
// v0.6.2 v0.2.1 true
|
||||
// v0.7.0 v0.3.0 true
|
||||
// v0.4.0 false
|
||||
}
|
||||
256
modules/kiosk/identity_test.go
Normal file
256
modules/kiosk/identity_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package kiosk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeTree writes files (slash paths relative to root) under root.
|
||||
func writeTree(t *testing.T, root string, files map[string]string) {
|
||||
t.Helper()
|
||||
for rel, body := range files {
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireGo skips a test that runs the go command when it is missing.
|
||||
func requireGo(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go command not found")
|
||||
}
|
||||
}
|
||||
|
||||
const pluginHead = "package p\n\ntype Plugin struct{}\n\n"
|
||||
|
||||
// TestReadIdentity reads ID() and Requires() from fixtures of every
|
||||
// supported shape and refuses the rest with the function named.
|
||||
func TestReadIdentity(t *testing.T) {
|
||||
requireGo(t)
|
||||
ctx := context.Background()
|
||||
gomod := "module example.test/p\n\ngo 1.27.0\n"
|
||||
|
||||
accepted := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want Identity
|
||||
}{
|
||||
{"literal", map[string]string{
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
|
||||
func (*Plugin) Requires() []string { return []string{"acme.b", "acme.c"} }`,
|
||||
}, Identity{ID: "acme.a", Requires: []string{"acme.b", "acme.c"}}},
|
||||
{"parenthesized literal", map[string]string{
|
||||
"plugin.go": pluginHead + `func (Plugin) ID() string { return ("acme.a") }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"same-file const", map[string]string{
|
||||
"plugin.go": pluginHead + `const id = "acme.a"
|
||||
func (*Plugin) ID() string { return id }
|
||||
func (*Plugin) Requires() []string { return nil }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"const in another file", map[string]string{
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`,
|
||||
"ids.go": "package p\n\nconst PluginID = \"acme.a\"\n",
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"chained and concatenated consts", map[string]string{
|
||||
"plugin.go": pluginHead + `const (
|
||||
vendor = "acme"
|
||||
name = base
|
||||
base = "a"
|
||||
full = vendor + "." + name
|
||||
)
|
||||
func (*Plugin) ID() string { return full }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"cross-package same module", map[string]string{
|
||||
"go.mod": gomod,
|
||||
"ids/ids.go": "package ids\n\nconst A = \"acme.a\"\nconst B = \"acme.b\"\n",
|
||||
"plugin.go": pluginHead[:len("package p\n\n")] + `import "example.test/p/ids"
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (*Plugin) ID() string { return ids.A }
|
||||
func (*Plugin) Requires() []string { return []string{ids.B} }`,
|
||||
}, Identity{ID: "acme.a", Requires: []string{"acme.b"}}},
|
||||
{"named import alias", map[string]string{
|
||||
"go.mod": gomod,
|
||||
"names/ids.go": "package names\n\nconst A = \"acme.a\"\n",
|
||||
"plugin.go": `package p
|
||||
|
||||
import x "example.test/p/names"
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (*Plugin) ID() string { return x.A }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"package name differs from path", map[string]string{
|
||||
"go.mod": gomod,
|
||||
"go-idents/ids.go": "package idents\n\nconst A = \"acme.a\"\n",
|
||||
"plugin.go": `package p
|
||||
|
||||
import "example.test/p/go-idents"
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (*Plugin) ID() string { return idents.A }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"requires empty slice", map[string]string{
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
|
||||
func (*Plugin) Requires() []string { return []string{} }`,
|
||||
}, Identity{ID: "acme.a", Requires: []string{}}},
|
||||
{"requires mixed literals and consts", map[string]string{
|
||||
"plugin.go": pluginHead + `const user = "acme.user"
|
||||
func (*Plugin) ID() string { return "acme.a" }
|
||||
func (*Plugin) Requires() []string { return []string{user, "acme.translate"} }`,
|
||||
}, Identity{ID: "acme.a", Requires: []string{"acme.user", "acme.translate"}}},
|
||||
{"no Requires method", map[string]string{
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }`,
|
||||
}, Identity{ID: "acme.a"}},
|
||||
{"plugin.go wins over a model's ID", map[string]string{
|
||||
"a_model.go": "package p\n\ntype Post struct{}\n\nfunc (Post) ID() string { return \"post\" }\nfunc (Post) Requires() []string { return []string{\"x.y\"} }\n",
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
|
||||
func (*Plugin) Requires() []string { return []string{"acme.b"} }`,
|
||||
}, Identity{ID: "acme.a", Requires: []string{"acme.b"}}},
|
||||
{"test files are ignored", map[string]string{
|
||||
"plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`,
|
||||
"ids.go": "package p\n\nconst PluginID = \"acme.a\"\n",
|
||||
"plugin_test.go": "package p\n\nconst PluginID = \"acme.test\"\n",
|
||||
}, Identity{ID: "acme.a"}},
|
||||
}
|
||||
for _, tc := range accepted {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{"go.mod": gomod}
|
||||
for k, v := range tc.files {
|
||||
files[k] = v
|
||||
}
|
||||
writeTree(t, dir, files)
|
||||
got, err := ReadIdentity(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
if got.ID != tc.want.ID || !slices.Equal(got.Requires, tc.want.Requires) || (got.Requires == nil) != (tc.want.Requires == nil) {
|
||||
t.Errorf("identity = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("other module through a replace", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTree(t, root, map[string]string{
|
||||
"dep/go.mod": "module example.test/dep\n\ngo 1.27.0\n",
|
||||
"dep/consts/ids.go": "package consts\n\nconst Shop = \"acme.shop\"\nconst User = \"acme.user\"\n",
|
||||
"plugin/go.mod": "module example.test/p\n\ngo 1.27.0\n\nrequire example.test/dep v0.3.0\n\nreplace example.test/dep => ../dep\n",
|
||||
"plugin/plugin.go": `package p
|
||||
|
||||
import "example.test/dep/consts"
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (*Plugin) ID() string { return consts.Shop }
|
||||
func (*Plugin) Requires() []string { return []string{consts.User} }`,
|
||||
})
|
||||
got, err := ReadIdentity(ctx, filepath.Join(root, "plugin"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
if got.ID != "acme.shop" || !slices.Equal(got.Requires, []string{"acme.user"}) {
|
||||
t.Errorf("identity = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
refused := []struct {
|
||||
name, src, want string
|
||||
extra map[string]string
|
||||
keepsID bool
|
||||
}{
|
||||
{"function call", `func (*Plugin) ID() string { return id() }
|
||||
func id() string { return "acme.x" }`, "ID()", nil, false},
|
||||
{"var", `var pluginID = "acme.x"
|
||||
func (*Plugin) ID() string { return pluginID }`, "ID()", nil, false},
|
||||
{"concatenation with a call", `func (*Plugin) ID() string { return "acme." + suffix() }
|
||||
func suffix() string { return "x" }`, "ID()", nil, false},
|
||||
{"empty ID", `func (*Plugin) ID() string { return "" }`, "empty string", nil, false},
|
||||
{"no return", `func (*Plugin) ID() string { panic("x") }`, "ID()", nil, false},
|
||||
{"self-referencing const", `const a = b
|
||||
const b = a
|
||||
func (*Plugin) ID() string { return a }`, "refers to itself", nil, false},
|
||||
{"const without value", `const (
|
||||
a = "acme.a"
|
||||
b
|
||||
)
|
||||
func (*Plugin) ID() string { return b }`, "no explicit value", nil, false},
|
||||
{"unknown import", `func (*Plugin) ID() string { return ids.A }`, "no import provides package ids", nil, false},
|
||||
{"import outside the module", "", "no import provides package ids", map[string]string{"plugin.go": `package p
|
||||
|
||||
import "example.test/elsewhere/ids"
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (*Plugin) ID() string { return ids.A }`}, false},
|
||||
{"missing ID method", `func (*Plugin) Name() string { return "acme.x" }`, "no ID() method", nil, false},
|
||||
{"requires var", `var deps = []string{"acme.y"}
|
||||
func (*Plugin) ID() string { return "acme.x" }
|
||||
func (*Plugin) Requires() []string { return deps }`, "Requires()", nil, true},
|
||||
{"requires array", `func (*Plugin) ID() string { return "acme.x" }
|
||||
func (*Plugin) Requires() []string { s := [1]string{"acme.y"}; return s[:] }`, "Requires()", nil, true},
|
||||
{"requires element var", `var dep = "acme.y"
|
||||
func (*Plugin) ID() string { return "acme.x" }
|
||||
func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()", nil, true},
|
||||
}
|
||||
for _, tc := range refused {
|
||||
t.Run("refuse "+tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{"go.mod": gomod}
|
||||
if tc.src != "" {
|
||||
files["plugin.go"] = pluginHead + tc.src + "\n"
|
||||
}
|
||||
for k, v := range tc.extra {
|
||||
files[k] = v
|
||||
}
|
||||
writeTree(t, dir, files)
|
||||
got, err := ReadIdentity(ctx, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want one naming %q", err, tc.want)
|
||||
}
|
||||
if tc.keepsID != (got.ID == "acme.x") {
|
||||
t.Errorf("identity = %#v; the ID is kept only when Requires() fails", got)
|
||||
}
|
||||
if tc.name != "missing ID method" && !strings.Contains(err.Error(), dir) {
|
||||
t.Errorf("err = %v, want the plugin directory named", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("not a directory", func(t *testing.T) {
|
||||
if _, err := ReadIdentity(ctx, filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Error("ReadIdentity of a missing directory succeeded")
|
||||
}
|
||||
empty := t.TempDir()
|
||||
if _, err := ReadIdentity(ctx, empty); err == nil || !strings.Contains(err.Error(), "no Go files") {
|
||||
t.Errorf("empty directory: err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLastElem(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"example.test/a/b": "b",
|
||||
"example.test/a/v2": "a",
|
||||
"example.test/go-yaml": "yaml",
|
||||
"v2": "v2",
|
||||
} {
|
||||
if got := lastElem(in); got != want {
|
||||
t.Errorf("lastElem(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
263
modules/kiosk/manifest_test.go
Normal file
263
modules/kiosk/manifest_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package kiosk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fullManifest is a valid manifest with every field set.
|
||||
func fullManifest() Manifest {
|
||||
return Manifest{
|
||||
Schema: SchemaVersion,
|
||||
ID: "acme.shop",
|
||||
Module: "example.test/sm-shop-plugin",
|
||||
Package: "example.test/sm-shop-plugin/shop",
|
||||
Name: "Shop",
|
||||
Description: "Products, carts and orders.",
|
||||
Framework: "v0.6.0",
|
||||
Requires: Requires{"acme.user": "v0.1.2", "acme.payment": "", "acme.audit": "v1.0.0"},
|
||||
Overrides: &Overrides{Name: "Shop"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestManifestFormat pins the plugin.yaml bytes, the parse rules and the
|
||||
// validation table.
|
||||
func TestManifestFormat(t *testing.T) {
|
||||
t.Run("marshal is stable and ordered", func(t *testing.T) {
|
||||
want := Header + "\n" +
|
||||
"schema: 1\n" +
|
||||
"id: acme.shop\n" +
|
||||
"module: example.test/sm-shop-plugin\n" +
|
||||
"package: example.test/sm-shop-plugin/shop\n" +
|
||||
"name: Shop\n" +
|
||||
"description: Products, carts and orders.\n" +
|
||||
"framework: v0.6.0\n" +
|
||||
"requires:\n" +
|
||||
" acme.audit: v1.0.0\n" +
|
||||
" acme.payment: \"\"\n" +
|
||||
" acme.user: v0.1.2\n" +
|
||||
"overrides:\n" +
|
||||
" name: Shop\n"
|
||||
for i := 0; i < 20; i++ {
|
||||
got, err := Marshal(fullManifest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("run %d: Marshal =\n%s\nwant\n%s", i, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty requires and overrides are left out", func(t *testing.T) {
|
||||
m := fullManifest()
|
||||
m.Requires = Requires{}
|
||||
m.Overrides = &Overrides{}
|
||||
got, err := Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(got), "requires") || strings.Contains(string(got), "overrides") {
|
||||
t.Errorf("Marshal kept empty blocks:\n%s", got)
|
||||
}
|
||||
m.Requires = nil
|
||||
m.Overrides = nil
|
||||
again, err := Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(again) != string(got) {
|
||||
t.Errorf("nil and empty differ:\n%s\n%s", got, again)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse round-trips", func(t *testing.T) {
|
||||
raw, err := Marshal(fullManifest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
again, err := Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(again) != string(raw) {
|
||||
t.Errorf("round trip changed the bytes:\n%s\n%s", raw, again)
|
||||
}
|
||||
if v, ok := m.Requires["acme.payment"]; !ok || v != "" {
|
||||
t.Errorf("empty minimum lost: %v", m.Requires)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse refusals", func(t *testing.T) {
|
||||
raw, err := Marshal(fullManifest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newer := strings.Replace(string(raw), "schema: 1", "schema: 2", 1)
|
||||
if _, err := Parse([]byte(newer)); err == nil || !strings.Contains(err.Error(), "update summer") {
|
||||
t.Errorf("newer schema: err = %v, want one saying to update summer", err)
|
||||
}
|
||||
if _, err := Parse([]byte("schema: [1\n")); err == nil || !strings.Contains(err.Error(), FileName) {
|
||||
t.Errorf("broken YAML: err = %v", err)
|
||||
}
|
||||
bad := strings.Replace(string(raw), "id: acme.shop", "id: Acme.Shop", 1)
|
||||
if _, err := Parse([]byte(bad)); err == nil || !strings.Contains(err.Error(), "vendor.plugin") {
|
||||
t.Errorf("invalid id: err = %v", err)
|
||||
}
|
||||
// A field a later schema-1 writer adds is ignored, as documented
|
||||
// on Parse: older summer binaries keep reading newer files.
|
||||
extra := string(raw) + "homepage: https://example.test\n"
|
||||
m, err := Parse([]byte(extra))
|
||||
if err != nil {
|
||||
t.Fatalf("unknown field: %v", err)
|
||||
}
|
||||
if m.ID != "acme.shop" {
|
||||
t.Errorf("unknown field changed the manifest: %+v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate", func(t *testing.T) {
|
||||
if err := Validate(fullManifest()); err != nil {
|
||||
t.Fatalf("Validate(full) = %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Manifest)
|
||||
want string
|
||||
}{
|
||||
{"schema zero", func(m *Manifest) { m.Schema = 0 }, "schema"},
|
||||
{"schema newer", func(m *Manifest) { m.Schema = SchemaVersion + 1 }, "schema"},
|
||||
{"id upper", func(m *Manifest) { m.ID = "Acme.shop" }, "id"},
|
||||
{"id no vendor", func(m *Manifest) { m.ID = "shop" }, "id"},
|
||||
{"id three parts", func(m *Manifest) { m.ID = "acme.shop.x" }, "id"},
|
||||
{"id digit first", func(m *Manifest) { m.ID = "1acme.shop" }, "id"},
|
||||
{"module empty", func(m *Manifest) { m.Module = "" }, "module"},
|
||||
{"module dash", func(m *Manifest) { m.Module = "-x/y"; m.Package = m.Module }, "module"},
|
||||
{"module space", func(m *Manifest) { m.Module = "example.test/a b"; m.Package = m.Module }, "module"},
|
||||
{"module dotdot", func(m *Manifest) { m.Module = "example.test/../x"; m.Package = m.Module }, "module"},
|
||||
{"module version", func(m *Manifest) { m.Module = "example.test/x@v1"; m.Package = m.Module }, "module"},
|
||||
{"package shell", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin/$(id)" }, "package"},
|
||||
{"package outside", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin-x" }, "not inside"},
|
||||
{"name", func(m *Manifest) { m.Name = "\t" }, "name"},
|
||||
{"description", func(m *Manifest) { m.Description = "" }, "description"},
|
||||
{"framework bare", func(m *Manifest) { m.Framework = "0.6.0" }, "framework"},
|
||||
{"framework empty", func(m *Manifest) { m.Framework = "" }, "framework"},
|
||||
{"framework describe", func(m *Manifest) { m.Framework = "v0.6.0-3-gabcdef1" }, "framework"},
|
||||
{"required id", func(m *Manifest) { m.Requires = Requires{"User": ""} }, "required id"},
|
||||
{"required minimum", func(m *Manifest) { m.Requires = Requires{"acme.user": "main"} }, "minimum"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := fullManifest()
|
||||
tc.mutate(&m)
|
||||
err := Validate(m)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: Validate = %v, want an error naming %q", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
ok := []func(*Manifest){
|
||||
func(m *Manifest) { m.Package = m.Module },
|
||||
func(m *Manifest) { m.Framework = "v0.6.1-0.20261029101500-abcdefabcdef" },
|
||||
func(m *Manifest) { m.Requires = Requires{"acme.user": "v0.1.3-0.20261029101500-abcdefabcdef"} },
|
||||
func(m *Manifest) { m.Requires = nil; m.Overrides = nil },
|
||||
}
|
||||
for i, mutate := range ok {
|
||||
m := fullManifest()
|
||||
mutate(&m)
|
||||
if err := Validate(m); err != nil {
|
||||
t.Errorf("accepted case %d: Validate = %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheck covers the drift guard: missing, stale and fresh files, and
|
||||
// CheckT's single Fatalf.
|
||||
func TestCheck(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
|
||||
path := filepath.Join(dir, FileName)
|
||||
|
||||
if err := Check(ctx, dir); err != nil {
|
||||
t.Fatalf("Check on the committed fixture: %v", err)
|
||||
}
|
||||
var fresh fakeTB
|
||||
CheckT(&fresh, dir)
|
||||
if len(fresh.fatals) != 0 {
|
||||
t.Fatalf("CheckT on a fresh file: %q", fresh.fatals)
|
||||
}
|
||||
|
||||
committed, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := strings.Replace(string(committed), "name: Blog", "name: Weblog", 1)
|
||||
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var se *StaleError
|
||||
err = Check(ctx, dir)
|
||||
if !errors.As(err, &se) || se.Missing {
|
||||
t.Fatalf("Check on an edited file = %v, want a stale *StaleError", err)
|
||||
}
|
||||
if se.Path != path || string(se.Got) != stale || string(se.Want) != string(committed) {
|
||||
t.Errorf("StaleError = path %s got %q want %q", se.Path, se.Got, se.Want)
|
||||
}
|
||||
line := strings.Count(string(committed)[:strings.Index(string(committed), "name: Blog")], "\n") + 1
|
||||
wantMsg := `line ` + strconv.Itoa(line) + ` is "name: Weblog", want "name: Blog"`
|
||||
if !strings.Contains(err.Error(), wantMsg) {
|
||||
t.Errorf("stale message = %q, want it to carry %q", err.Error(), wantMsg)
|
||||
}
|
||||
var staleTB fakeTB
|
||||
CheckT(&staleTB, dir)
|
||||
if len(staleTB.fatals) != 1 || !strings.Contains(staleTB.fatals[0], "summer plugin:manifest "+dir) {
|
||||
t.Errorf("CheckT on a stale file: %q", staleTB.fatals)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = Check(ctx, dir)
|
||||
if !errors.As(err, &se) || !se.Missing || !strings.Contains(err.Error(), "is missing") {
|
||||
t.Fatalf("Check without the file = %v, want Missing", err)
|
||||
}
|
||||
var missingTB fakeTB
|
||||
CheckT(&missingTB, dir)
|
||||
if len(missingTB.fatals) != 1 {
|
||||
t.Errorf("CheckT on a missing file: %q", missingTB.fatals)
|
||||
}
|
||||
|
||||
var broken fakeTB
|
||||
CheckT(&broken, t.TempDir())
|
||||
if len(broken.fatals) != 1 {
|
||||
t.Errorf("CheckT on a directory without a plugin: %q", broken.fatals)
|
||||
}
|
||||
|
||||
t.Run("stale message", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
want, got, msg string
|
||||
}{
|
||||
{"a\nb\n", "a\nc\n", `line 2 is "c", want "b"`},
|
||||
{"a\nb\n", "a\nb\nextra\n", `line 3 is "extra", want ""`},
|
||||
{"a\nb\nc\n", "a\n", `line 2 is "", want "b"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := &StaleError{Path: "p/plugin.yaml", Want: []byte(c.want), Got: []byte(c.got)}
|
||||
if !strings.Contains(e.Error(), c.msg) || !strings.HasPrefix(e.Error(), "kiosk: p/plugin.yaml is stale") {
|
||||
t.Errorf("StaleError(%q, %q) = %q, want %q", c.want, c.got, e.Error(), c.msg)
|
||||
}
|
||||
}
|
||||
same := &StaleError{Path: "p", Want: []byte("x"), Got: []byte("x")}
|
||||
if same.Error() != "kiosk: p is stale" {
|
||||
t.Errorf("equal bytes: %q", same.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
141
modules/kiosk/version_test.go
Normal file
141
modules/kiosk/version_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package kiosk
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestVersions pins the version rules the registry, plugin:install,
|
||||
// plugin:update and the admin share.
|
||||
func TestVersions(t *testing.T) {
|
||||
t.Run("ValidVersion", func(t *testing.T) {
|
||||
for v, want := range map[string]bool{
|
||||
"v0.0.0": true, "v0.6.0": true, "v1.20.300": true, "v12345678901234567890.0.1": true,
|
||||
"0.6.0": false, "v0.6": false, "v0.6.0.1": false, "v0.6.0-rc.1": false, "v0.6.0+build": false,
|
||||
"v00.6.0": false, "v0.06.0": false, "v0.6.00": false, "V0.6.0": false, " v0.6.0": false,
|
||||
"v0.6.0\n": false, "v-1.0.0": false, "": false, "v0.6.0-1-gabcdef0": false,
|
||||
} {
|
||||
if got := ValidVersion(v); got != want {
|
||||
t.Errorf("ValidVersion(%q) = %v, want %v", v, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CompareVersions", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"v0.0.0", "v0.0.0", 0}, {"v0.0.1", "v0.0.0", 1}, {"v0.1.0", "v0.0.99", 1},
|
||||
{"v1.0.0", "v0.99.99", 1}, {"v0.9.0", "v0.10.0", -1}, {"v0.10.0", "v0.9.0", 1},
|
||||
{"v99999999999999999999.0.0", "v99999999999999999998.9.9", 1},
|
||||
{"v0.5.3", "v0.6.0", -1}, {"v0.6.0", "v0.6.0", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := CompareVersions(c.a, c.b); got != c.want {
|
||||
t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
if got := CompareVersions(c.b, c.a); got != -c.want {
|
||||
t.Errorf("CompareVersions(%s, %s) = %d, want %d (antisymmetric)", c.b, c.a, got, -c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BaseVersion", func(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"v0.6.0": "v0.6.0",
|
||||
"v0.6.0-12-gdeadbee": "v0.6.0",
|
||||
"v0.6.0-1-g0123456789ab": "v0.6.0",
|
||||
"v0.6.0-dirty": "",
|
||||
"v0.6.0-12-gdeadbee-dirty": "",
|
||||
"v0.6.0-rc.1": "",
|
||||
"v0.6.0-rc.1-2-gabcdef0": "",
|
||||
"deadbee": "",
|
||||
"0.6.0-1-gabcdef0": "",
|
||||
"v0.6.0-x-gabcdef0": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := BaseVersion(in); got != want {
|
||||
t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pseudo-versions", func(t *testing.T) {
|
||||
for v, want := range map[string]bool{
|
||||
"v0.0.0-20261029101500-abcdefabcdef": true,
|
||||
"v0.6.1-0.20261029101500-abcdefabcdef": true,
|
||||
"v0.6.0-rc.1.0.20261029101500-abcdefabcdef": true,
|
||||
"v0.6.1-0.2026102910150-abcdefabcdef": false,
|
||||
"v0.6.1-0.20261029101500-abcdefabcde": false,
|
||||
"v0.6.1": false,
|
||||
"v0.6.1-12-gabcdef0": false,
|
||||
} {
|
||||
if got := isPseudoVersion(v); got != want {
|
||||
t.Errorf("isPseudoVersion(%q) = %v, want %v", v, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Satisfies", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
have, min string
|
||||
want bool
|
||||
}{
|
||||
{"v0.6.0", "", true}, {"", "", true}, {"garbage", "", true},
|
||||
{"v0.6.0", "v0.6.0", true}, {"v0.6.1", "v0.6.0", true}, {"v1.0.0", "v0.6.0", true},
|
||||
{"v0.5.9", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.6.0-3-gabcdef0", "v0.6.0", false},
|
||||
// A pseudo-version minimum vX.Y.Z-0.<ts>-<hash> sorts just
|
||||
// below vX.Y.Z: the release itself meets it, the one before
|
||||
// does not.
|
||||
{"v0.6.1", "v0.6.1-0.20261029101500-abcdefabcdef", true},
|
||||
{"v0.7.0", "v0.6.1-0.20261029101500-abcdefabcdef", true},
|
||||
{"v0.6.0", "v0.6.1-0.20261029101500-abcdefabcdef", false},
|
||||
{"v0.6.0", "v0.6.0-rc.1.0.20261029101500-abcdefabcdef", true},
|
||||
{"v0.0.1", "v0.0.0-20261029101500-abcdefabcdef", true},
|
||||
{"v0.6.0", "latest", false}, {"v0.6.0", "v0.6", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Satisfies(c.have, c.min); got != c.want {
|
||||
t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NewestCompatible", func(t *testing.T) {
|
||||
if _, ok := NewestCompatible(nil, "v0.6.0"); ok {
|
||||
t.Error("nil list found a version")
|
||||
}
|
||||
if _, ok := NewestCompatible([]RegistryVersion{}, "v0.6.0"); ok {
|
||||
t.Error("empty list found a version")
|
||||
}
|
||||
incompatible := []RegistryVersion{{Version: "v0.2.0", Framework: "v0.7.0"}, {Version: "v0.3.0", Framework: "v1.0.0"}}
|
||||
if got, ok := NewestCompatible(incompatible, "v0.6.0"); ok {
|
||||
t.Errorf("all incompatible found %+v", got)
|
||||
}
|
||||
unsorted := []RegistryVersion{
|
||||
{Version: "v0.1.0", Framework: "v0.5.0"},
|
||||
{Version: "v0.10.0", Framework: "v0.6.0", Commit: "ten"},
|
||||
{Version: "v0.9.0", Framework: "v0.5.0"},
|
||||
{Version: "v0.11.0", Framework: "v0.7.0"},
|
||||
{Version: "v0.12.0-rc.1", Framework: ""},
|
||||
{Version: "main", Framework: ""},
|
||||
{Version: "v0.2.0", Framework: ""},
|
||||
}
|
||||
got, ok := NewestCompatible(unsorted, "v0.6.0")
|
||||
if !ok || got.Version != "v0.10.0" || got.Commit != "ten" {
|
||||
t.Errorf("unsorted = %+v, %v; want v0.10.0", got, ok)
|
||||
}
|
||||
if got, ok := NewestCompatible(unsorted, "v0.7.0"); !ok || got.Version != "v0.11.0" {
|
||||
t.Errorf("framework v0.7.0 = %+v, %v; want v0.11.0", got, ok)
|
||||
}
|
||||
// A framework that is not a release only meets empty minimums.
|
||||
if got, ok := NewestCompatible(unsorted, ""); !ok || got.Version != "v0.2.0" {
|
||||
t.Errorf("unknown framework = %+v, %v; want v0.2.0", got, ok)
|
||||
}
|
||||
pseudo := []RegistryVersion{{Version: "v0.4.0", Framework: "v0.6.1-0.20261029101500-abcdefabcdef"}}
|
||||
if _, ok := NewestCompatible(pseudo, "v0.6.0"); ok {
|
||||
t.Error("pseudo-version framework minimum met by the release before it")
|
||||
}
|
||||
if _, ok := NewestCompatible(pseudo, "v0.6.1"); !ok {
|
||||
t.Error("pseudo-version framework minimum not met by its release")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user