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) }