Files
summercms/internal/build/fixtures_test.go
Jakub Zych 9b23e877a6 feat(build): summer plugin:install for registry plugins as Go modules
- kiosk.Client reads the registry API v1 over fetchguard (trusted mode,
  4 MiB, 10 s, no redirects); kiosk.RegistryURL reads plugins.registry_url
  (default https://summercms.io, empty disables)
- build.PlanInstall/ApplyInstall pick the newest release the effective
  framework satisfies (or a pin), validate registry strings, go get it and
  append it to summer.yaml
- summer.yaml edits (plugin:add and plugin:install) keep comments and
  blank lines
- TestInstallSmoke/module installs from a file:// GOPROXY and builds
2026-10-29 18:22:01 +01:00

267 lines
7.8 KiB
Go

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 <module>@<version>/ 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)
}