package build import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "git.golem15.com/golem15/summercms/modules/kiosk" ) // memRegistry is an in-memory Registry that counts its calls. type memRegistry struct { mu sync.Mutex versions map[string][]kiosk.RegistryVersion details map[string]kiosk.RegistryPluginDetail calls []string fail error } func (r *memRegistry) List(context.Context, string, string) (kiosk.ListResponse, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, "list") return kiosk.ListResponse{}, r.fail } func (r *memRegistry) Plugin(_ context.Context, id string) (kiosk.RegistryPluginDetail, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, "plugin "+id) if r.fail != nil { return kiosk.RegistryPluginDetail{}, r.fail } d, ok := r.details[id] if !ok { return d, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) } return d, nil } func (r *memRegistry) Versions(_ context.Context, id string) ([]kiosk.RegistryVersion, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, "versions "+id) if r.fail != nil { return nil, r.fail } v, ok := r.versions[id] if !ok { return nil, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) } return v, nil } // add lists id with releases given as version:framework pairs, all in // module example.test/, requiring reqs at every release. func (r *memRegistry) add(id string, reqs kiosk.Requires, releases ...string) { mod := "example.test/" + pluginName(id) r.addIn(id, mod, mod, reqs, releases...) } func (r *memRegistry) addIn(id, mod, pkg string, reqs kiosk.Requires, releases ...string) { if r.versions == nil { r.versions = map[string][]kiosk.RegistryVersion{} r.details = map[string]kiosk.RegistryPluginDetail{} } if _, ok := r.versions[id]; !ok { r.versions[id] = []kiosk.RegistryVersion{} } for _, rel := range releases { v, fw, _ := strings.Cut(rel, ":") r.versions[id] = append(r.versions[id], kiosk.RegistryVersion{Version: v, Framework: fw, Requires: reqs, Module: mod, Package: pkg}) } d := kiosk.RegistryPluginDetail{} d.ID, d.Module, d.Package = id, mod, pkg d.Repository = "https://example.test/sm-" + pluginName(id) + "-plugin.git" r.details[id] = d } // installApp writes a minimal application (summer.yaml and a go.mod that // requires the framework at framework, without a replace) and returns its // directory. extra are summer.yaml plugins and go.mod requires to add. func installApp(t *testing.T, framework string, installed map[string]string) string { t.Helper() dir := t.TempDir() yaml := "# The test application.\nmodule: example.test/app\nbinary: app\nplugins:\n" gomod := "module example.test/app\n\ngo 1.27.0\n\nrequire " + frameworkModule + " " + framework + "\n" if len(installed) == 0 { yaml = "module: example.test/app\nbinary: app\n" } for _, id := range sortedKeys(kiosk.Requires(installed)) { mod := "example.test/" + pluginName(id) yaml += " - id: " + id + "\n module: " + mod + "\n" gomod += "require " + mod + " " + installed[id] + "\n" } write(t, filepath.Join(dir, manifestName), yaml) write(t, filepath.Join(dir, "go.mod"), gomod) return dir } func planSteps(p *InstallPlan) string { var parts []string for _, s := range p.Steps { parts = append(parts, s.ID+"@"+s.Version) } return strings.Join(parts, ",") } // TestResolveInstall pins the resolution rules of plugin:install against // an in-memory registry: newest compatible release, pins, framework // errors, requirement closure and order, installed requirements, cycles, // offline and disabled registries and refused registry strings. func TestResolveInstall(t *testing.T) { ctx := t.Context() plan := func(app, arg string, reg Registry, mutate ...func(*InstallOptions)) (*InstallPlan, string, error) { t.Helper() var out bytes.Buffer opts := InstallOptions{StartDir: app, Arg: arg, Yes: true, Registry: reg, Out: &out} for _, m := range mutate { m(&opts) } p, err := PlanInstall(ctx, opts) return p, out.String(), err } t.Run("newest compatible", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", nil, "v0.3.0:v0.7.0", "v0.10.0:v0.6.0", "v0.2.0:v0.6.0", "v0.1.0:v0.5.0", "v0.4.0-rc.1:v0.5.0") app := installApp(t, "v0.6.0", nil) p, out, err := plan(app, "acme.shop", reg) if err != nil { t.Fatalf("plan: %v", err) } if got := planSteps(p); got != "acme.shop@v0.10.0" { t.Errorf("steps = %s", got) } if p.Framework != "v0.6.0" || p.AppDir != app || p.Steps[0].Mode != ModeModule || p.Steps[0].Module != "example.test/shop" { t.Errorf("plan = %+v", p) } if !strings.Contains(out, "Installing 1 plugin into "+app+" (framework v0.6.0):\n acme.shop@v0.10.0 (module)\n") { t.Errorf("summary:\n%s", out) } }) t.Run("pin", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", nil, "v0.3.0:v0.7.0", "v0.2.0:v0.6.0", "v0.1.0:v0.5.0") app := installApp(t, "v0.6.0", nil) if p, _, err := plan(app, "acme.shop@v0.1.0", reg); err != nil || planSteps(p) != "acme.shop@v0.1.0" { t.Errorf("compatible pin: %v, %v", p, err) } _, _, err := plan(app, "acme.shop@v0.3.0", reg) if err == nil || !strings.Contains(err.Error(), "acme.shop v0.3.0 needs framework v0.7.0 or newer; this application builds against v0.6.0") || !strings.Contains(fullError(err), "go get "+frameworkModule+"@v0.7.0") { t.Errorf("incompatible pin: %v (%s)", err, fullError(err)) } _, _, err = plan(app, "acme.shop@v0.9.0", reg) if err == nil || !strings.Contains(err.Error(), "no release v0.9.0; available: v0.3.0, v0.2.0, v0.1.0") { t.Errorf("unknown pin: %v", err) } }) t.Run("no compatible release names the minimum framework", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", nil, "v0.2.0:v0.9.0", "v0.3.0:v0.8.0") app := installApp(t, "v0.6.0", nil) _, _, err := plan(app, "acme.shop", reg) if err == nil || !strings.Contains(err.Error(), "acme.shop v0.3.0 needs framework v0.8.0 or newer") { t.Errorf("err = %v, want the newest release's framework named", err) } empty := &memRegistry{} empty.add("acme.shop", nil) if _, _, err := plan(app, "acme.shop", empty); err == nil || !strings.Contains(err.Error(), "has no release in the registry") { t.Errorf("no releases: %v", err) } }) t.Run("requirement closure in dependency order", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.0", "acme.payment": "v0.2.0"}, "v0.1.0:v0.6.0") reg.add("acme.payment", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0") reg.add("acme.user", nil, "v0.1.5:v0.7.0", "v0.1.3:v0.6.0", "v0.1.0:v0.5.0") app := installApp(t, "v0.6.0", nil) p, out, err := plan(app, "acme.shop", reg) if err != nil { t.Fatalf("plan: %v", err) } if got := planSteps(p); got != "acme.user@v0.1.3,acme.payment@v0.3.0,acme.shop@v0.1.0" { t.Errorf("steps = %s", got) } if !strings.Contains(out, "Installing 3 plugins") { t.Errorf("summary:\n%s", out) } }) t.Run("requirement minimum above the newest compatible release", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.2.0"}, "v0.1.0:v0.6.0") reg.add("acme.user", nil, "v0.2.0:v0.7.0", "v0.1.9:v0.6.0") app := installApp(t, "v0.6.0", nil) _, _, err := plan(app, "acme.shop", reg) if err == nil || !strings.Contains(err.Error(), "acme.user v0.2.0 needs framework v0.7.0") { t.Errorf("err = %v", err) } reg.add("acme.lonely", kiosk.Requires{"acme.user": "v0.5.0"}, "v0.1.0:v0.6.0") _, _, err = plan(app, "acme.lonely", reg) if err == nil || !strings.Contains(err.Error(), "acme.user has no release at or above the required minimum v0.5.0") { t.Errorf("err = %v", err) } }) t.Run("plugins of one module share a release", func(t *testing.T) { reg := &memRegistry{} reg.addIn("acme.pg", "example.test/pg", "example.test/pg", nil, "v0.2.0:v0.6.0", "v0.1.0:v0.6.0") reg.addIn("acme.pgsubs", "example.test/pg", "example.test/pg/subs", kiosk.Requires{"acme.pg": ""}, "v0.2.0:v0.6.0", "v0.1.0:v0.6.0") app := installApp(t, "v0.6.0", nil) p, _, err := plan(app, "acme.pgsubs@v0.1.0", reg) if err != nil { t.Fatalf("plan: %v", err) } if got := planSteps(p); got != "acme.pg@v0.1.0,acme.pgsubs@v0.1.0" { t.Errorf("steps = %s, want both at the pinned module release", got) } }) t.Run("installed requirements", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.1.0:v0.6.0") reg.add("acme.user", nil, "v0.1.3:v0.6.0") old := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.1"}) _, _, err := plan(old, "acme.shop", reg) if err == nil || !strings.Contains(err.Error(), "acme.shop needs acme.user v0.1.2 or newer, but the application has v0.1.1") || !strings.Contains(fullError(err), "plugin:update acme.user") { t.Errorf("too old: %v (%s)", err, fullError(err)) } fresh := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.2"}) p, _, err := plan(fresh, "acme.shop", reg) if err != nil || planSteps(p) != "acme.shop@v0.1.0" { t.Errorf("installed at the minimum: %v, %v", p, err) } _, _, err = plan(fresh, "acme.user", reg) if err == nil || !strings.Contains(err.Error(), "already in summer.yaml") || !strings.Contains(fullError(err), "plugin:update acme.user") { t.Errorf("installing an installed plugin: %v", err) } }) t.Run("cycle", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.a", kiosk.Requires{"acme.b": ""}, "v0.1.0:v0.6.0") reg.add("acme.b", kiosk.Requires{"acme.c": ""}, "v0.1.0:v0.6.0") reg.add("acme.c", kiosk.Requires{"acme.a": ""}, "v0.1.0:v0.6.0") _, _, err := plan(installApp(t, "v0.6.0", nil), "acme.a", reg) if err == nil || !strings.Contains(err.Error(), "dependency cycle in the registry data: acme.a -> acme.b -> acme.c -> acme.a") { t.Errorf("err = %v", err) } }) t.Run("offline and disabled", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", nil, "v0.1.0:v0.6.0") app := installApp(t, "v0.6.0", nil) _, _, err := plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Offline = true }) if err == nil || !strings.Contains(err.Error(), "--offline makes no registry calls") || !strings.Contains(fullError(err), "plugin:install ") { t.Errorf("offline: %v (%s)", err, fullError(err)) } if len(reg.calls) != 0 { t.Errorf("offline made registry calls: %v", reg.calls) } _, _, err = plan(app, "acme.shop", nil) if err == nil || !strings.Contains(err.Error(), "plugins.registry_url is empty") { t.Errorf("disabled: %v", err) } reg.fail = errors.New("connection refused") _, _, err = plan(app, "acme.shop", reg) if err == nil || !strings.Contains(err.Error(), "connection refused") { t.Errorf("registry down: %v", err) } }) t.Run("refused registry strings", func(t *testing.T) { app := installApp(t, "v0.6.0", nil) cases := map[string]struct { mod, pkg string reqs kiosk.Requires release string want string }{ "module flag": {"-modfile=/tmp/x", "-modfile=/tmp/x", nil, "v0.1.0:v0.6.0", "module"}, "module shell": {"example.test/x;rm", "example.test/x;rm", nil, "v0.1.0:v0.6.0", "module"}, "package outside": {"example.test/x", "example.test/y", nil, "v0.1.0:v0.6.0", "not inside module"}, "required id": {"example.test/x", "example.test/x", kiosk.Requires{"Bad ID": ""}, "v0.1.0:v0.6.0", "refused"}, "version": {"example.test/x", "example.test/x", nil, "v0.1:v0.6.0", "has no release in the registry"}, } for name, c := range cases { reg := &memRegistry{} reg.addIn("acme.x", c.mod, c.pkg, c.reqs, c.release) _, _, err := plan(app, "acme.x", reg) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: err = %v, want %q", name, err, c.want) } } for _, arg := range []string{"", "acme", "Acme.Shop", "acme.shop@latest", "acme.shop@v1", "acme.shop@v1.0.0-rc.1", "-x.y"} { if _, _, err := plan(app, arg, &memRegistry{}); err == nil { t.Errorf("arg %q accepted", arg) } } }) t.Run("prompt", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", nil, "v0.1.0:v0.6.0") app := installApp(t, "v0.6.0", nil) before, _ := os.ReadFile(filepath.Join(app, manifestName)) _, out, err := plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Yes = false }) if err == nil || !strings.Contains(err.Error(), "pass --yes") || !strings.Contains(out, "acme.shop@v0.1.0 (module)") { t.Errorf("non-interactive: %v\n%s", err, out) } var question string _, _, err = plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Yes, o.Interactive = false, true o.Confirm = func(q string) (bool, error) { question = q; return false, nil } }) if !errors.Is(err, ErrCancelled) || question != "Install 1 plugin?" { t.Errorf("declined: %v, asked %q", err, question) } _, _, err = plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Yes, o.Interactive = false, true o.Confirm = func(string) (bool, error) { return false, errors.New("tty closed") } }) if err == nil || !strings.Contains(err.Error(), "tty closed") { t.Errorf("prompt error: %v", err) } if after, _ := os.ReadFile(filepath.Join(app, manifestName)); !bytes.Equal(before, after) { t.Error("planning changed summer.yaml") } }) t.Run("not an application", func(t *testing.T) { if _, _, err := plan(t.TempDir(), "acme.shop", &memRegistry{}); err == nil { t.Error("PlanInstall outside an application succeeded") } }) t.Run("apply refuses an empty or tampered plan", func(t *testing.T) { if err := ApplyInstall(ctx, nil, nil); err == nil { t.Error("ApplyInstall(nil) succeeded") } bad := &InstallPlan{AppDir: t.TempDir(), Steps: []InstallStep{{ID: "acme.x", Version: "v0.1.0", Module: "-x", Package: "-x", Mode: ModeModule}}} if err := ApplyInstall(ctx, bad, nil); err == nil || !strings.Contains(err.Error(), "refused") { t.Errorf("tampered plan: %v", err) } mode := &InstallPlan{AppDir: t.TempDir(), Steps: []InstallStep{{ID: "acme.x", Version: "v0.1.0", Module: "example.test/x", Package: "example.test/x", Mode: "zip"}}} if err := ApplyInstall(ctx, mode, nil); err == nil || !strings.Contains(err.Error(), "unknown install mode") { t.Errorf("unknown mode: %v", err) } }) } func TestAskToProceed(t *testing.T) { yes := func(string) (bool, error) { return true, nil } if err := askToProceed(true, false, nil, "q"); err != nil { t.Errorf("--yes: %v", err) } if err := askToProceed(false, false, yes, "q"); err == nil || !strings.Contains(err.Error(), "--yes") { t.Errorf("non-interactive: %v", err) } if err := askToProceed(false, true, nil, "q"); err == nil || !strings.Contains(err.Error(), "--yes") { t.Errorf("interactive without a prompt: %v", err) } if err := askToProceed(false, true, yes, "q"); err != nil { t.Errorf("accepted: %v", err) } }