package build import ( "archive/zip" "encoding/json" "fmt" "io/fs" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "testing" "time" "git.golem15.com/golem15/summercms/modules/kiosk" ) // writeProxyModule adds modPath@version to the file:// GOPROXY tree at // proxyDir: @v/list, .info, .mod (files["go.mod"]) and .zip, whose // entries are prefixed @/ as the module zip format // requires. Module paths in fixtures are lower case, so no escaping is // needed. func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) { t.Helper() if strings.ToLower(modPath) != modPath { t.Fatalf("fixture module path %q must be lower case", modPath) } gomod, ok := files["go.mod"] if !ok { t.Fatalf("fixture %s@%s has no go.mod", modPath, version) } dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } list, _ := os.ReadFile(filepath.Join(dir, "list")) if !strings.Contains("\n"+string(list), "\n"+version+"\n") { list = append(list, []byte(version+"\n")...) } write(t, filepath.Join(dir, "list"), string(list)) info, _ := json.Marshal(map[string]string{"Version": version, "Time": "2026-10-01T00:00:00Z"}) write(t, filepath.Join(dir, version+".info"), string(info)) write(t, filepath.Join(dir, version+".mod"), gomod) f, err := os.Create(filepath.Join(dir, version+".zip")) if err != nil { t.Fatal(err) } zw := zip.NewWriter(f) names := make([]string, 0, len(files)) for name := range files { names = append(names, name) } sort.Strings(names) for _, name := range names { w, err := zw.Create(modPath + "@" + version + "/" + name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(files[name])); err != nil { t.Fatal(err) } } if err := zw.Close(); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } } // fixturePluginFiles is a compiling plugin package with the given ID in // a module that requires the framework at framework. func fixturePluginFiles(modPath, pkg, id, framework string, extraRequires ...string) map[string]string { var req strings.Builder req.WriteString("require (\n\t" + frameworkModule + " " + framework + "\n") for _, r := range extraRequires { req.WriteString("\t" + r + "\n") } req.WriteString(")\n") gomod := "module " + modPath + "\n\ngo 1.27.0\n\n" + req.String() src := fmt.Sprintf(`package %s import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/party" ) // Plugin is the %s fixture plugin. type Plugin struct{} func (p *Plugin) ID() string { return %q } func (p *Plugin) Requires() []string { return nil } func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Boot(*backpack.App) error { return nil } func init() { party.Register(&Plugin{}) } `, pkg, id, id) return map[string]string{"go.mod": gomod, "plugin.go": src} } // offlineGoEnv points the go commands of the test at the file:// proxy // tree proxyDir, falling back to the developer's module download cache // for every other module, with a fresh module cache and no checksum // database: no network is used. The fresh cache is made writable again // before the temp directory is removed, because summer's go runner drops // GOFLAGS (and with it -modcacherw). func offlineGoEnv(t *testing.T, proxyDir string) { t.Helper() out, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE").Output() if err != nil { t.Fatalf("go env GOMODCACHE: %v", err) } realCache := strings.TrimSpace(string(out)) modCache := t.TempDir() t.Cleanup(func() { makeWritable(modCache) }) t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxyDir)+",file://"+filepath.ToSlash(filepath.Join(realCache, "cache", "download"))) t.Setenv("GOMODCACHE", modCache) t.Setenv("GOSUMDB", "off") t.Setenv("GOFLAGS", "-modcacherw") t.Setenv("GOTOOLCHAIN", "local") t.Setenv("GONOSUMDB", "") t.Setenv("GOPRIVATE", "") t.Setenv("GONOPROXY", "") } func makeWritable(root string) { _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err == nil { _ = os.Chmod(path, 0o755) } return nil }) } // fakePlugin is one plugin a fakeRegistry serves. type fakePlugin struct { Detail kiosk.RegistryPluginDetail Versions []kiosk.RegistryVersion } // fakeReg is an httptest registry speaking the kiosk v1 wire types. type fakeReg struct { Client *kiosk.Client URL string mu sync.Mutex plugins map[string]fakePlugin requests []string } // fakeRegistry serves plugins through httptest and returns a real // kiosk.Client for it, so tests exercise the HTTP client too. func fakeRegistry(t *testing.T, plugins map[string]fakePlugin) *fakeReg { t.Helper() r := &fakeReg{plugins: plugins} mux := http.NewServeMux() notFound := func(w http.ResponseWriter, id string) { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: id + " not found"}}) } mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins", func(w http.ResponseWriter, req *http.Request) { r.record(req) r.mu.Lock() defer r.mu.Unlock() var resp kiosk.ListResponse for _, p := range r.plugins { resp.Data = append(resp.Data, p.Detail.RegistryPlugin) } resp.Meta.Total = len(resp.Data) _ = json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}", func(w http.ResponseWriter, req *http.Request) { r.record(req) r.mu.Lock() defer r.mu.Unlock() id := req.PathValue("id") p, ok := r.plugins[id] if !ok { notFound(w, id) return } _ = json.NewEncoder(w).Encode(kiosk.DetailResponse{Data: p.Detail}) }) mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}/versions", func(w http.ResponseWriter, req *http.Request) { r.record(req) r.mu.Lock() defer r.mu.Unlock() id := req.PathValue("id") p, ok := r.plugins[id] if !ok { notFound(w, id) return } _ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: p.Versions}) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) client, err := kiosk.NewClient(srv.URL, nil) if err != nil { t.Fatal(err) } r.Client, r.URL = client, srv.URL return r } func (r *fakeReg) record(req *http.Request) { r.mu.Lock() defer r.mu.Unlock() r.requests = append(r.requests, req.URL.Path) } // Requests returns the request paths served so far. func (r *fakeReg) Requests() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.requests...) } // set replaces (or adds) one plugin. func (r *fakeReg) set(id string, p fakePlugin) { r.mu.Lock() defer r.mu.Unlock() r.plugins[id] = p } // regVersion is a registry release of a module plugin. func regVersion(version, framework, module, pkg string, requires kiosk.Requires) kiosk.RegistryVersion { return kiosk.RegistryVersion{ Version: version, Framework: framework, Requires: requires, Module: module, Package: pkg, Commit: strings.Repeat("a", 40), TaggedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), } } // regPlugin is a registry listing whose versions are given newest first. func regPlugin(id, repo string, versions ...kiosk.RegistryVersion) fakePlugin { p := fakePlugin{Versions: versions} p.Detail.ID = id p.Detail.Name = id p.Detail.Repository = repo if len(versions) > 0 { p.Detail.Module = versions[0].Module p.Detail.Package = versions[0].Package p.Detail.LatestVersion = versions[0].Version p.Detail.LatestFramework = versions[0].Framework p.Detail.Requires = versions[0].Requires } return p } // helloCopy is a copy of examples/hello in a temp directory whose go.mod // replaces the framework with this checkout (absolute path), as // TestMakeAndAddPluginOnHelloCopy uses it. func helloCopy(t *testing.T) string { t.Helper() return copyHelloApp(t) } // fixtureRelease is one tagged commit of a fixture plugin repository. type fixtureRelease struct { Tag string Framework string // Requires maps required plugin IDs to {module, minimum}. Requires map[string][2]string } // pluginRepoFiles is a plugin repository at one release: go.mod in the // development layout of the shared plugin repos (the framework and every // required plugin replaced with sibling paths that do not exist in an // application), plugin.go and the generated plugin.yaml. func pluginRepoFiles(t *testing.T, id, module string, rel fixtureRelease) map[string]string { t.Helper() var gomod strings.Builder gomod.WriteString("module " + module + "\n\ngo 1.27.0\n\nrequire (\n\t" + frameworkModule + " " + rel.Framework + "\n") reqIDs := make([]string, 0, len(rel.Requires)) for rid := range rel.Requires { reqIDs = append(reqIDs, rid) } sort.Strings(reqIDs) for _, rid := range reqIDs { gomod.WriteString("\t" + rel.Requires[rid][0] + " " + rel.Requires[rid][1] + "\n") } gomod.WriteString(")\n\nreplace " + frameworkModule + " => ../summercms.go\n") var imports, reqList strings.Builder requires := kiosk.Requires{} for _, rid := range reqIDs { gomod.WriteString("\nreplace " + rel.Requires[rid][0] + " => ../sm-" + pluginName(rid) + "-plugin\n") imports.WriteString("\t_ " + fmt.Sprintf("%q", rel.Requires[rid][0]) + "\n") reqList.WriteString(fmt.Sprintf("%q, ", rid)) requires[rid] = rel.Requires[rid][1] } pkg := pluginName(id) src := fmt.Sprintf(`package %s import ( %s "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/party" ) // Plugin is the %s fixture plugin. type Plugin struct{} func (p *Plugin) ID() string { return %q } func (p *Plugin) Requires() []string { return []string{%s} } func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Boot(*backpack.App) error { return nil } func init() { party.Register(&Plugin{}) } `, pkg, imports.String(), id, id, reqList.String()) m := kiosk.Manifest{ Schema: kiosk.SchemaVersion, ID: id, Module: module, Package: module, Name: "Fixture " + pkg, Description: "A fixture plugin for install tests.", Framework: rel.Framework, } if len(requires) > 0 { m.Requires = requires } yml, err := kiosk.Marshal(m) if err != nil { t.Fatal(err) } return map[string]string{"go.mod": gomod.String(), "plugin.go": src, kiosk.FileName: string(yml)} } // pluginRepo creates a git repository with one tagged commit per release // and returns its file:// URL. head, when set, adds an untagged commit on // top with that framework minimum. func pluginRepo(t *testing.T, id, module string, releases []fixtureRelease, head *fixtureRelease) string { t.Helper() dir := t.TempDir() runTestGit(t, dir, "init", "--quiet") commit := func(rel fixtureRelease, tag bool) { for name, body := range pluginRepoFiles(t, id, module, rel) { write(t, filepath.Join(dir, name), body) } write(t, filepath.Join(dir, "CHANGES"), "release "+rel.Tag+"\n") runTestGit(t, dir, "add", "-A") runTestGit(t, dir, "commit", "--quiet", "-m", "release "+rel.Tag) if tag { runTestGit(t, dir, "tag", rel.Tag) } } for _, rel := range releases { commit(rel, true) } if head != nil { commit(*head, false) } return "file://" + filepath.ToSlash(dir) } // gitTestEnv isolates git from the developer's config, lets the CLI git // runner use file:// fixture repositories and keeps the go commands off // the network. func gitTestEnv(t *testing.T) { t.Helper() isolateGit(t) old := cliGitProtocols cliGitProtocols = "https:ssh:file" t.Cleanup(func() { cliGitProtocols = old }) t.Setenv("GOPROXY", "off") t.Setenv("GOFLAGS", "") t.Setenv("GOTOOLCHAIN", "local") } // gitApp is a helloCopy committed to a fresh git repository. func gitApp(t *testing.T) string { t.Helper() app := helloCopy(t) runTestGit(t, app, "init", "--quiet") runTestGit(t, app, "add", "-A") runTestGit(t, app, "commit", "--quiet", "-m", "app") return app } // installTemps lists leftover temporary checkouts under plugins/. func installTemps(t *testing.T, app string) []string { t.Helper() matches, err := filepath.Glob(filepath.Join(app, "plugins", installTemp+"*")) if err != nil { t.Fatal(err) } return matches }