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) } } // TestUpdatePseudoVersion pins CR-01: a module required at a // pseudo-version (a commit after a release, as go get M@main leaves it) is // never moved to the release it builds on or an older one, and one with no // release before its commit is not moved at all. func TestUpdatePseudoVersion(t *testing.T) { const after040 = "v0.4.1-0.20261001120000-abcdef123456" plan := func(t *testing.T, installed string, releases ...string) (string, error) { t.Helper() reg := &memRegistry{} reg.add("acme.blog", nil, releases...) app := installApp(t, "v0.6.0", map[string]string{"acme.blog": installed}) var out bytes.Buffer err := Update(t.Context(), UpdateOptions{ StartDir: app, ID: "acme.blog", Registry: reg, Out: &out, Interactive: true, Confirm: func(string) (bool, error) { return false, nil }, }) return out.String(), err } t.Run("only the base release and older", func(t *testing.T) { out, err := plan(t, after040, "v0.4.0:v0.6.0", "v0.3.0:v0.6.0") if err != nil { t.Fatalf("update: %v\n%s", err, out) } if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "up to date at "+after040) || strings.Contains(out, "-> v0.4.0") { t.Errorf("a commit after v0.4.0 is moved back:\n%s", out) } }) t.Run("a newer release", func(t *testing.T) { out, err := plan(t, after040, "v0.4.1:v0.6.0", "v0.4.0:v0.6.0") if !errors.Is(err, ErrCancelled) { t.Fatalf("update: %v\n%s", err, out) } if !strings.Contains(out, "acme.blog "+after040+" -> v0.4.1 (module)") { t.Errorf("plan:\n%s", out) } }) t.Run("no release before the commit", func(t *testing.T) { out, err := plan(t, "v0.0.0-20261001120000-abcdef123456", "v0.1.0:v0.6.0") if err != nil { t.Fatalf("update: %v\n%s", err, out) } if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "plugin:update does not guess") || !strings.Contains(out, "go get example.test/blog@") { t.Errorf("an unplaceable commit is moved:\n%s", out) } }) t.Run("a requirement at a pseudo-version", func(t *testing.T) { reg := &memRegistry{} reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.1.0:v0.6.0") app := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.3-0.20261001120000-abcdef123456"}) var out bytes.Buffer p, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.shop", Yes: true, Registry: reg, Out: &out}) if err != nil || planSteps(p) != "acme.shop@v0.1.0" || !strings.Contains(out.String(), "cannot be read") { t.Errorf("plan: %v, %v\n%s", p, err, out.String()) } }) } // TestInstallSharedModule pins CR-02: a plugin in a module the // application already has installs at the version the application has, // fetches nothing, and is refused when that version has no release of it. func TestInstallSharedModule(t *testing.T) { reg := &memRegistry{} reg.addIn("acme.pg", "example.test/pg", "example.test/pg", nil, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0") reg.addIn("acme.pgsubs", "example.test/pg", "example.test/pg/subs", nil, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0") reg.addIn("acme.pgnew", "example.test/pg", "example.test/pg/fresh", nil, "v0.3.0:v0.6.0") reg.addIn("acme.shop", "example.test/shop", "example.test/shop", kiosk.Requires{"acme.pgsubs": ""}, "v0.1.0:v0.6.0") plan := func(t *testing.T, app, arg string) (*InstallPlan, string, error) { t.Helper() var out bytes.Buffer p, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: arg, Yes: true, Registry: reg, Out: &out}) return p, out.String(), err } t.Run("pinned to the installed module version", func(t *testing.T) { app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"}) p, out, err := plan(t, app, "acme.pgsubs") if err != nil { t.Fatalf("plan: %v\n%s", err, out) } if got := planSteps(p); got != "acme.pgsubs@v0.2.0" || !p.Steps[0].existing { t.Fatalf("steps = %s %+v, want acme.pgsubs at the installed v0.2.0", got, p.Steps) } if !strings.Contains(out, "acme.pgsubs@v0.2.0 (module, module already in the application)") { t.Errorf("plan output:\n%s", out) } gomod, _ := os.ReadFile(filepath.Join(app, "go.mod")) // ApplyInstall runs no go get for it: only summer.yaml changes. if err := ApplyInstall(t.Context(), p, nil); err != nil { t.Fatalf("apply: %v", err) } after, _ := os.ReadFile(filepath.Join(app, "go.mod")) if !bytes.Equal(gomod, after) { t.Errorf("go.mod changed:\n%s", after) } yaml, _ := os.ReadFile(filepath.Join(app, manifestName)) if !strings.Contains(string(yaml), "id: acme.pgsubs\n module: example.test/pg/subs") { t.Errorf("summer.yaml:\n%s", yaml) } }) t.Run("a requirement in the installed module", func(t *testing.T) { app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"}) p, out, err := plan(t, app, "acme.shop") if err != nil || planSteps(p) != "acme.pgsubs@v0.2.0,acme.shop@v0.1.0" { t.Fatalf("plan: %v %v\n%s", p, err, out) } }) t.Run("refused when the installed version has no release of it", func(t *testing.T) { app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"}) for _, arg := range []string{"acme.pgnew", "acme.pgsubs@v0.3.0"} { _, out, err := plan(t, app, arg) if err == nil || !strings.Contains(err.Error(), "lives in example.test/pg, which the application already has at v0.2.0 (for acme.pg)") || !strings.Contains(fullError(err), "plugin:update acme.pg") { t.Errorf("%s: %v\n%s", arg, fullError(err), out) } } }) t.Run("refused at a pseudo-version", func(t *testing.T) { app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.1-0.20261001120000-abcdef123456"}) _, out, err := plan(t, app, "acme.pgsubs") if err == nil || !strings.Contains(err.Error(), "not a release plugin:install can pin") { t.Errorf("err = %v\n%s", fullError(err), out) } }) }