diff --git a/modules/kiosk/client_test.go b/modules/kiosk/client_test.go new file mode 100644 index 00000000..1996edb7 --- /dev/null +++ b/modules/kiosk/client_test.go @@ -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("bad gateway")) + 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) + } + }) +} diff --git a/modules/kiosk/derive_test.go b/modules/kiosk/derive_test.go new file mode 100644 index 00000000..bcf0fe1e --- /dev/null +++ b/modules/kiosk/derive_test.go @@ -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 := "\n\n# The `acme` *Shop*\n\n" + + "Products, [carts](https://example.test/carts) and **orders**\n" + + "for `acme` apps, see .\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) + } +} diff --git a/modules/kiosk/example_test.go b/modules/kiosk/example_test.go new file mode 100644 index 00000000..a857ac7b --- /dev/null +++ b/modules/kiosk/example_test.go @@ -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 +} diff --git a/modules/kiosk/identity_test.go b/modules/kiosk/identity_test.go new file mode 100644 index 00000000..9794505f --- /dev/null +++ b/modules/kiosk/identity_test.go @@ -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) + } + } +} diff --git a/modules/kiosk/manifest_test.go b/modules/kiosk/manifest_test.go new file mode 100644 index 00000000..62c30c95 --- /dev/null +++ b/modules/kiosk/manifest_test.go @@ -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()) + } + }) +} diff --git a/modules/kiosk/version_test.go b/modules/kiosk/version_test.go new file mode 100644 index 00000000..79d31005 --- /dev/null +++ b/modules/kiosk/version_test.go @@ -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.- 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") + } + }) +} diff --git a/modules/party/info_test.go b/modules/party/info_test.go new file mode 100644 index 00000000..9a893066 --- /dev/null +++ b/modules/party/info_test.go @@ -0,0 +1,61 @@ +package party + +import ( + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// TestDescribe covers publishing the build-time plugin metadata. +func TestDescribe(t *testing.T) { + if err := Describe(nil, nil, "v0.6.0"); err == nil || !strings.Contains(err.Error(), "app is nil") { + t.Errorf("Describe(nil) = %v", err) + } + + app := backpack.New(&compass.Config{}) + if _, ok := app.Lookup[Installed](); ok { + t.Fatal("a fresh app already has Installed") + } + plugins := []Info{ + {ID: "acme.user", Module: "example.test/sm-user-plugin", Package: "example.test/sm-user-plugin", Name: "User", Version: "v0.1.2", Source: SourceModule}, + {ID: "acme.shop", Package: "example.test/app/plugins/acme/shop", Source: SourceLocal}, + } + if err := Describe(app, plugins, "v0.6.0"); err != nil { + t.Fatalf("Describe: %v", err) + } + // The caller's slice is copied: changing it later does not change + // what the app published. + plugins[0].Version = "v9.9.9" + got, ok := app.Lookup[Installed]() + if !ok { + t.Fatal("Installed not published") + } + if got.Framework != "v0.6.0" || len(got.Plugins) != 2 || got.Plugins[0].Version != "v0.1.2" || got.Plugins[1].Source != SourceLocal { + t.Errorf("Installed = %+v", got) + } + + // Describe publishes once per app (the generated main calls it once, + // before Activate); a second call is refused and the first stays. + if err := Describe(app, nil, "v0.7.0"); err == nil || !strings.Contains(err.Error(), "party:") { + t.Errorf("second Describe = %v, want a duplicate error", err) + } + if again, _ := app.Lookup[Installed](); again.Framework != "v0.6.0" || len(again.Plugins) != 2 { + t.Errorf("after a second Describe: %+v", again) + } + + empty := backpack.New(&compass.Config{}) + if err := Describe(empty, nil, ""); err != nil { + t.Fatalf("Describe with no plugins: %v", err) + } + if got, ok := empty.Lookup[Installed](); !ok || got.Plugins == nil || len(got.Plugins) != 0 || got.Framework != "" { + t.Errorf("empty Installed = %+v, %v", got, ok) + } + + for _, s := range []string{SourceModule, SourceSubmodule, SourceLocal} { + if s == "" || strings.ToLower(s) != s { + t.Errorf("source constant %q", s) + } + } +}