Files
summercms/modules/surf/admin_prefix_test.go
Jakub Zych da21670870 feat(surf): print the serve URL and admin status on startup
- Add surf.Router.AdminPrefix (additive): the admin mount path and true
  when BuildRouter mounted the cabana admin, false otherwise
- serve binds with net.Listen before printing, so a busy port fails
  without a misleading URL, then prints the listen address with a
  browser URL and an admin status line: the admin URL, a warning naming
  summer admin:create when backend_users is empty or missing, or
  admin: off with the steps to add an admin controller
- The administrator count is bounded by a 2 s timeout and never fails boot
- route:list ends with the same admin status line
- surf and cabana READMEs and the console and routing docs describe the output
2026-10-08 16:36:21 +02:00

236 lines
7.8 KiB
Go

package surf
import (
"bytes"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// TestPhase10AdminPrefixCollision fails boot when a plugin other than cabana
// registers a route at or under the admin prefix (backend.uri), which would
// otherwise shadow or be shadowed by the admin SPA and API.
func TestPhase10AdminPrefixCollision(t *testing.T) {
for _, tc := range []struct {
name string
method string
path string
raw bool
}{
{"exact prefix", http.MethodGet, "/acme-admin", false},
{"under prefix", http.MethodPost, "/acme-admin/hook", false},
{"raw under api", http.MethodGet, "/acme-admin/api/v1/extra", true},
{"deeper path", http.MethodGet, "/acme-admin/a/b/c", false},
{"exact prefix raw", http.MethodPost, "/acme-admin", true},
} {
t.Run(tc.name, func(t *testing.T) {
app := adminPrefixApp(t)
plugins := []party.Plugin{
adminPrefixPlugin{},
prefixRoutePlugin{id: "acme.intruder", method: tc.method, path: tc.path, raw: tc.raw},
}
_, err := BuildRouter(app, plugins)
if err == nil {
t.Fatalf("BuildRouter accepted %s %s under the admin prefix", tc.method, tc.path)
}
for _, want := range []string{tc.method, tc.path, "acme.intruder", "/acme-admin"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not name %q", err, want)
}
}
})
}
t.Run("sibling path is allowed", func(t *testing.T) {
app := adminPrefixApp(t)
plugins := []party.Plugin{
adminPrefixPlugin{},
prefixRoutePlugin{id: "acme.neighbour", method: http.MethodGet, path: "/acme-adminx"},
}
if _, err := BuildRouter(app, plugins); err != nil {
t.Fatalf("sibling path rejected: %v", err)
}
})
t.Run("default /backend prefix", func(t *testing.T) {
for _, tc := range []struct {
path string
collide bool
}{
{"/backend", true},
{"/backend/deep/hook", true},
{"/backendx", false},
{"/back", false},
{"/api/backend", false},
} {
app := adminPrefixAppWith(t, "")
plugins := []party.Plugin{
adminPrefixPlugin{},
prefixRoutePlugin{id: "acme.intruder", method: http.MethodGet, path: tc.path},
}
_, err := BuildRouter(app, plugins)
if tc.collide && (err == nil || !strings.Contains(err.Error(), "/backend")) {
t.Fatalf("%s under the default prefix: err=%v", tc.path, err)
}
if !tc.collide && err != nil {
t.Fatalf("%s rejected next to the default prefix: %v", tc.path, err)
}
}
})
t.Run("no admin means no prefix check", func(t *testing.T) {
app := adminPrefixApp(t)
plugins := []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/acme-admin"}}
if _, err := BuildRouter(app, plugins); err != nil {
t.Fatalf("a route at the prefix without admin controllers was rejected: %v", err)
}
if err := (&Router{}).checkAdminPrefix(""); err != nil {
t.Fatalf("empty prefix: %v", err)
}
})
}
func adminPrefixApp(t *testing.T) *backpack.App {
t.Helper()
return adminPrefixAppWith(t, "/acme-admin")
}
// adminPrefixAppWith configures backend.uri; an empty uri keeps the default.
func adminPrefixAppWith(t *testing.T, uri string) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: admin-prefix\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"), 0o644); err != nil {
t.Fatal(err)
}
if uri != "" {
if err := os.WriteFile(filepath.Join(dir, "backend.yaml"), []byte("uri: "+uri+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"},
})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
type adminPrefixPlugin struct{}
func (adminPrefixPlugin) ID() string { return "acme.demo" }
func (adminPrefixPlugin) Requires() []string { return nil }
func (adminPrefixPlugin) Register(*backpack.App) error { return nil }
func (adminPrefixPlugin) Boot(*backpack.App) error { return nil }
func (adminPrefixPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{adminPrefixController{}}
}
func (adminPrefixPlugin) AdminFS() fs.FS {
return fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
}
}
type adminPrefixController struct{}
func (adminPrefixController) ID() string { return "acme.demo.widgets" }
func (adminPrefixController) ModelName() string { return "Widget" }
func (adminPrefixController) ConfigDir() string { return "controllers/widgets" }
type prefixRoutePlugin struct {
id string
method string
path string
raw bool
}
func (p prefixRoutePlugin) ID() string { return p.id }
func (p prefixRoutePlugin) Requires() []string { return nil }
func (p prefixRoutePlugin) Register(*backpack.App) error { return nil }
func (p prefixRoutePlugin) Boot(*backpack.App) error { return nil }
func (p prefixRoutePlugin) Routes(r pact.Router) error {
h := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
open := r.Group
if p.raw {
open = r.GroupRaw
}
open("", nil, func(g pact.Router) {
switch p.method {
case http.MethodPost:
g.Post(p.path, h)
default:
g.Get(p.path, h)
}
})
return nil
}
// TestRouterAdminPrefix reports the admin mount path BuildRouter used.
func TestRouterAdminPrefix(t *testing.T) {
for _, tc := range []struct {
name string
app *backpack.App
plugins []party.Plugin
prefix string
mounted bool
}{
{"configured prefix", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "/acme-admin", true},
{"default prefix", adminPrefixAppWith(t, ""), []party.Plugin{adminPrefixPlugin{}}, "/backend", true},
{"no admin controllers", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "", false},
} {
t.Run(tc.name, func(t *testing.T) {
r, err := BuildRouter(tc.app, tc.plugins)
if err != nil {
t.Fatal(err)
}
prefix, mounted := r.AdminPrefix()
if prefix != tc.prefix || mounted != tc.mounted {
t.Fatalf("AdminPrefix() = (%q, %v), want (%q, %v)", prefix, mounted, tc.prefix, tc.mounted)
}
})
}
if prefix, mounted := (*Router)(nil).AdminPrefix(); prefix != "" || mounted {
t.Fatalf("nil router AdminPrefix() = (%q, %v)", prefix, mounted)
}
}
// TestRouteListAdminStatus ends the route table with the admin status line.
func TestRouteListAdminStatus(t *testing.T) {
for _, tc := range []struct {
name string
app *backpack.App
plugins []party.Plugin
want string
}{
{"mounted", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "admin: mounted at /acme-admin"},
{"off", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "admin: off — no plugin registers an admin controller"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if err := RouteListCommand(tc.app, tc.plugins).Run(t.Context(), nil, out); err != nil {
t.Fatal(err)
}
got := strings.TrimRight(buf.String(), "\n")
if !strings.HasSuffix(got, tc.want) {
t.Fatalf("route:list output does not end with %q:\n%s", tc.want, got)
}
})
}
}