package build import ( "bytes" "errors" "os" "path/filepath" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/kiosk" ) const ( blogModule = "example.com/acme/blog" // plantedComment is written into summer.yaml before an install; the // install must keep it. plantedComment = "# planted: plugin:install keeps this comment" ) // plantComment puts a comment line and a blank line above the plugins // list of the app's summer.yaml. func plantComment(t *testing.T, appDir string) { t.Helper() path := filepath.Join(appDir, manifestName) raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } s := strings.Replace(string(raw), "plugins:\n", "\n"+plantedComment+"\nplugins:\n", 1) write(t, path, s) } func assertManifestKeepsComment(t *testing.T, appDir string) { t.Helper() raw, err := os.ReadFile(filepath.Join(appDir, manifestName)) if err != nil { t.Fatal(err) } if !bytes.Contains(raw, []byte(plantedComment)) { t.Fatalf("summer.yaml lost the planted comment:\n%s", raw) } } func manifestIDs(t *testing.T, appDir string) []string { t.Helper() m, err := LoadManifest(filepath.Join(appDir, manifestName)) if err != nil { t.Fatal(err) } ids := make([]string, len(m.Plugins)) for i, p := range m.Plugins { ids[i] = p.ID + "=" + p.Module } return ids } // fullError is the error text plus a bonfire hint's explanation and Try // line, where the hints name commands and flags. func fullError(err error) string { if err == nil { return "" } text := err.Error() var h *bonfire.HintError if errors.As(err, &h) { text += "\n" + h.Explanation + "\n" + h.Try } return text } func TestInstallSmoke(t *testing.T) { blogV1 := regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, nil) blogV2 := regVersion("v0.2.0", "v9.0.0", blogModule, blogModule, nil) t.Run("module", func(t *testing.T) { if testing.Short() { t.Skip("builds an app against a file:// module proxy") } app := helloCopy(t) plantComment(t, app) proxy := t.TempDir() writeProxyModule(t, proxy, blogModule, "v0.1.0", fixturePluginFiles(blogModule, "blog", "acme.blog", "v0.5.0")) offlineGoEnv(t, proxy) reg := fakeRegistry(t, map[string]fakePlugin{ "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), }) var out bytes.Buffer plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client, Out: &out}) if err != nil { t.Fatalf("plan: %v\n%s", err, out.String()) } if len(plan.Steps) != 1 || plan.Steps[0].Version != "v0.1.0" || plan.Steps[0].Mode != ModeModule { t.Fatalf("plan steps = %+v", plan.Steps) } if !strings.Contains(out.String(), "acme.blog@v0.1.0 (module)") { t.Fatalf("plan output lacks the step:\n%s", out.String()) } if err := ApplyInstall(t.Context(), plan, &out); err != nil { t.Fatalf("apply: %v\n%s", err, out.String()) } if !strings.Contains(out.String(), "run summer build") { t.Fatalf("apply output:\n%s", out.String()) } gomod, err := os.ReadFile(filepath.Join(app, "go.mod")) if err != nil { t.Fatal(err) } if !bytes.Contains(gomod, []byte(blogModule+" v0.1.0")) { t.Fatalf("go.mod does not require %s v0.1.0:\n%s", blogModule, gomod) } ids := manifestIDs(t, app) if last := ids[len(ids)-1]; last != "acme.blog="+blogModule { t.Fatalf("summer.yaml plugins = %v", ids) } assertManifestKeepsComment(t, app) var log bytes.Buffer if err := App(t.Context(), app, &log); err != nil { t.Fatalf("build: %v\n%s", err, log.String()) } gen, err := os.ReadFile(filepath.Join(app, "plugins.gen.go")) if err != nil { t.Fatal(err) } if !bytes.Contains(gen, []byte(`_ "`+blogModule+`"`)) || !bytes.Contains(gen, []byte(`Version: "v0.1.0"`)) { t.Fatalf("plugins.gen.go does not import %s at v0.1.0:\n%s", blogModule, gen) } }) t.Run("module_pin_framework", func(t *testing.T) { app := helloCopy(t) reg := fakeRegistry(t, map[string]fakePlugin{ "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), }) before := manifestIDs(t, app) _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog@v0.2.0", Yes: true, Registry: reg.Client}) if err == nil || !strings.Contains(err.Error(), "v9.0.0") { t.Fatalf("pin to v0.2.0: err = %v, want one naming framework v9.0.0", err) } if got := manifestIDs(t, app); strings.Join(got, ",") != strings.Join(before, ",") { t.Fatalf("summer.yaml changed: %v", got) } }) translateModule := "example.com/acme/translate" depsRegistry := func(t *testing.T) *fakeReg { return fakeRegistry(t, map[string]fakePlugin{ "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, kiosk.Requires{"acme.translate": "v0.1.0"})), "acme.translate": regPlugin("acme.translate", "https://example.com/acme/translate.git", regVersion("v0.2.0", "v9.0.0", translateModule, translateModule, nil), regVersion("v0.1.1", "v0.5.0", translateModule, translateModule, nil), regVersion("v0.1.0", "v0.5.0", translateModule, translateModule, nil), regVersion("v0.0.9", "v0.5.0", translateModule, translateModule, nil)), }) } stepList := func(p *InstallPlan) string { var parts []string for _, s := range p.Steps { parts = append(parts, s.ID+"@"+s.Version+"/"+s.Mode) } return strings.Join(parts, ",") } t.Run("deps", func(t *testing.T) { app := helloCopy(t) reg := depsRegistry(t) var out bytes.Buffer plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client, Out: &out}) if err != nil { t.Fatalf("plan: %v\n%s", err, out.String()) } if got := stepList(plan); got != "acme.translate@v0.1.1/module,acme.blog@v0.1.0/module" { t.Fatalf("steps = %s", got) } if !strings.Contains(out.String(), "acme.translate@v0.1.1 (module)\n acme.blog@v0.1.0 (module)") { t.Fatalf("summary:\n%s", out.String()) } if testing.Short() { return } proxy := t.TempDir() writeProxyModule(t, proxy, blogModule, "v0.1.0", fixturePluginFiles(blogModule, "blog", "acme.blog", "v0.5.0", translateModule+" v0.1.0")) writeProxyModule(t, proxy, translateModule, "v0.1.0", fixturePluginFiles(translateModule, "translate", "acme.translate", "v0.5.0")) writeProxyModule(t, proxy, translateModule, "v0.1.1", fixturePluginFiles(translateModule, "translate", "acme.translate", "v0.5.0")) offlineGoEnv(t, proxy) if err := ApplyInstall(t.Context(), plan, &out); err != nil { t.Fatalf("apply: %v\n%s", err, out.String()) } if n := strings.Count(out.String(), "go get "); n != 1 { t.Fatalf("want one go get, got %d:\n%s", n, out.String()) } gomod, _ := os.ReadFile(filepath.Join(app, "go.mod")) for _, want := range []string{blogModule + " v0.1.0", translateModule + " v0.1.1"} { if !bytes.Contains(gomod, []byte(want)) { t.Fatalf("go.mod lacks %s:\n%s", want, gomod) } } ids := manifestIDs(t, app) if got := strings.Join(ids[len(ids)-2:], ","); got != "acme.translate="+translateModule+",acme.blog="+blogModule { t.Fatalf("summer.yaml order = %v", ids) } }) t.Run("installed_old", func(t *testing.T) { app := helloCopy(t) if err := appendManifestPlugins(filepath.Join(app, manifestName), []Plugin{{ID: "acme.translate", Module: translateModule}}); err != nil { t.Fatal(err) } if err := runGo(t.Context(), app, "mod", "edit", "-require="+translateModule+"@v0.0.9"); err != nil { t.Fatal(err) } reg := depsRegistry(t) _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client}) if err == nil || !strings.Contains(err.Error(), "v0.0.9") || !strings.Contains(fullError(err), "summer plugin:update acme.translate") { t.Fatalf("err = %v (%s), want installed v0.0.9 too old and a plugin:update hint", err, fullError(err)) } // At the minimum it is fine, and only blog is planned. if err := runGo(t.Context(), app, "mod", "edit", "-require="+translateModule+"@v0.1.0"); err != nil { t.Fatal(err) } plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client}) if err != nil { t.Fatal(err) } if got := stepList(plan); got != "acme.blog@v0.1.0/module" { t.Fatalf("steps = %s", got) } }) t.Run("cycle", func(t *testing.T) { app := helloCopy(t) reg := fakeRegistry(t, map[string]fakePlugin{ "acme.one": regPlugin("acme.one", "", regVersion("v0.1.0", "v0.5.0", "example.com/acme/one", "example.com/acme/one", kiosk.Requires{"acme.two": "v0.1.0"})), "acme.two": regPlugin("acme.two", "", regVersion("v0.1.0", "v0.5.0", "example.com/acme/two", "example.com/acme/two", kiosk.Requires{"acme.one": "v0.1.0"})), }) done := make(chan error, 1) go func() { _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.one", Yes: true, Registry: reg.Client}) done <- err }() err := <-done if err == nil || !strings.Contains(err.Error(), "cycle") || !strings.Contains(err.Error(), "acme.one -> acme.two -> acme.one") { t.Fatalf("err = %v, want a reported cycle", err) } }) t.Run("pin", func(t *testing.T) { app := helloCopy(t) reg := depsRegistry(t) plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.translate@v0.1.0", Yes: true, Registry: reg.Client}) if err != nil { t.Fatal(err) } if got := stepList(plan); got != "acme.translate@v0.1.0/module" { t.Fatalf("steps = %s", got) } _, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.translate@v0.7.0", Yes: true, Registry: reg.Client}) if err == nil || !strings.Contains(err.Error(), "v0.7.0") || !strings.Contains(err.Error(), "v0.2.0, v0.1.1, v0.1.0, v0.0.9") { t.Fatalf("unknown pin: err = %v, want the available versions listed", err) } for _, bad := range []string{"acme.translate@latest", "acme.translate@v1.0", "Acme.Translate", "acme.translate@-v1.0.0"} { if _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: bad, Yes: true, Registry: reg.Client}); err == nil { t.Fatalf("%s: want an error", bad) } } }) t.Run("unknown", func(t *testing.T) { app := helloCopy(t) reg := depsRegistry(t) _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.nope", Yes: true, Registry: reg.Client}) if err == nil || !strings.Contains(err.Error(), "not in the registry") || !strings.Contains(err.Error(), reg.URL) { t.Fatalf("err = %v, want not in the registry at %s", err, reg.URL) } }) t.Run("prompt", func(t *testing.T) { app := helloCopy(t) reg := depsRegistry(t) before, _ := os.ReadFile(filepath.Join(app, manifestName)) var out bytes.Buffer _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Out: &out}) if err == nil || !strings.Contains(err.Error(), "--yes") { t.Fatalf("non-interactive without --yes: err = %v", err) } if !strings.Contains(out.String(), "acme.blog@v0.1.0 (module)") { t.Fatalf("plan not printed before the error:\n%s", out.String()) } asked := "" _, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Interactive: true, Confirm: func(q string) (bool, error) { asked = q; return false, nil }}) if !errors.Is(err, ErrCancelled) || asked == "" { t.Fatalf("declined prompt: err = %v, asked %q", err, asked) } plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Registry: reg.Client, Interactive: true, Confirm: func(string) (bool, error) { return true, nil }}) if err != nil || len(plan.Steps) != 2 { t.Fatalf("accepted prompt: plan %+v, err %v", plan, err) } after, _ := os.ReadFile(filepath.Join(app, manifestName)) if !bytes.Equal(before, after) { t.Fatal("planning changed summer.yaml") } }) t.Run("offline", func(t *testing.T) { app := helloCopy(t) reg := depsRegistry(t) _, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Offline: true, Registry: reg.Client}) if err == nil || !strings.Contains(err.Error(), "--offline") || !strings.Contains(fullError(err), "git") { t.Fatalf("offline: err = %v (%s)", err, fullError(err)) } if n := len(reg.Requests()); n != 0 { t.Fatalf("offline made %d registry requests", n) } _, err = PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true}) if err == nil || !strings.Contains(err.Error(), "plugins.registry_url") || !strings.Contains(fullError(err), "git") { t.Fatalf("disabled registry: err = %v (%s)", err, fullError(err)) } }) t.Run("invalid", func(t *testing.T) { app := helloCopy(t) for name, v := range map[string]kiosk.RegistryVersion{ "dash module": regVersion("v0.1.0", "v0.5.0", "-modfile=/tmp/x", "-modfile=/tmp/x", nil), "bad version": regVersion("v0.1", "v0.5.0", blogModule, blogModule, nil), "outside": regVersion("v0.1.0", "v0.5.0", blogModule, "example.com/other/pkg", nil), "bad requireid": regVersion("v0.1.0", "v0.5.0", blogModule, blogModule, kiosk.Requires{"-x": "v0.1.0"}), } { reg := fakeRegistry(t, map[string]fakePlugin{"acme.blog": regPlugin("acme.blog", "", v)}) plan, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.blog", Yes: true, Registry: reg.Client}) if err == nil { t.Fatalf("%s: accepted, plan %+v", name, plan.Steps) } if name == "bad version" { if !strings.Contains(err.Error(), "no release") && !strings.Contains(err.Error(), "refused") { t.Fatalf("%s: err = %v", name, err) } continue } if !strings.Contains(err.Error(), "refused") { t.Fatalf("%s: err = %v, want refused", name, err) } } // ApplyInstall checks again, so a hand-built plan cannot reach go. bad := &InstallPlan{AppDir: app, Steps: []InstallStep{{ID: "acme.blog", Version: "v0.1.0", Module: "-x", Package: "-x", Mode: ModeModule}}} if err := ApplyInstall(t.Context(), bad, nil); err == nil || !strings.Contains(err.Error(), "refused") { t.Fatalf("apply: err = %v", err) } }) t.Run("append_manifest", func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, manifestName) cases := []struct { name, in, want string }{ { name: "indented", in: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n\n # trailing note\nbuild:\n tags: [x]\n", want: "# app\nmodule: example.com/app\nbinary: app\n\n# the plugins\nplugins:\n - id: a.one\n module: example.com/one # first\n - id: acme.blog\n module: example.com/acme/blog\n\n # trailing note\nbuild:\n tags: [x]\n", }, { name: "flush", in: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n", want: "module: example.com/app\nbinary: app\nplugins:\n- id: a.one\n module: example.com/one\n- id: acme.blog\n module: example.com/acme/blog\n", }, { name: "empty", in: "module: example.com/app\nbinary: app\nplugins: []\n# end\n", want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n# end\n", }, { name: "absent", in: "module: example.com/app\nbinary: app", want: "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/acme/blog\n", }, } for _, c := range cases { write(t, path, c.in) if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err != nil { t.Fatalf("%s: %v", c.name, err) } got, _ := os.ReadFile(path) if string(got) != c.want { t.Fatalf("%s:\n got %q\nwant %q", c.name, got, c.want) } } inline := "module: example.com/app\nbinary: app\nplugins: [{id: a.one, module: example.com/one}]\n" write(t, path, inline) if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil { t.Fatal("inline plugins list: want an error") } if got, _ := os.ReadFile(path); string(got) != inline { t.Fatalf("inline list was changed: %q", got) } dup := "module: example.com/app\nbinary: app\nplugins:\n - id: acme.blog\n module: example.com/other\n" write(t, path, dup) if err := appendManifestPlugins(path, []Plugin{{ID: "acme.blog", Module: blogModule}}); err == nil { t.Fatal("duplicate ID: want an error") } if got, _ := os.ReadFile(path); string(got) != dup { t.Fatalf("summer.yaml not restored: %q", got) } }) t.Run("registry_client", func(t *testing.T) { reg := fakeRegistry(t, map[string]fakePlugin{ "acme.blog": regPlugin("acme.blog", "https://example.com/acme/blog.git", blogV2, blogV1), }) vs, err := reg.Client.Versions(t.Context(), "acme.blog") if err != nil || len(vs) != 2 || vs[0].Version != "v0.2.0" { t.Fatalf("versions = %+v, %v", vs, err) } _, err = reg.Client.Versions(t.Context(), "acme.nope") if !errors.Is(err, kiosk.ErrNotFound) || !strings.Contains(err.Error(), reg.URL) { t.Fatalf("unknown id: err = %v", err) } list, err := reg.Client.List(t.Context(), "blog", "") if err != nil || list.Meta.Total != 1 { t.Fatalf("list = %+v, %v", list, err) } if _, err := kiosk.NewClient("", nil); !errors.Is(err, kiosk.ErrRegistryDisabled) { t.Fatalf("empty URL: err = %v", err) } for _, bad := range []string{"ftp://x", "https://user:pw@x", "https://x/?q=1", "x"} { if _, err := kiosk.NewClient(bad, nil); err == nil { t.Fatalf("NewClient(%q) accepted", bad) } } }) }