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
This commit is contained in:
@@ -55,8 +55,8 @@ Copy the printed value into `SUMMER_APP__KEY`. When you rotate the key, keep the
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, builds the router, binds the address, starts the in-process job worker and serves HTTP until SIGINT or SIGTERM, then shuts down within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the way `serve` does, without opening the database or listening, and prints every route with its method, pattern, plugin, middleware and raw flag, followed by the admin status line. |
|
||||
|
||||
```sh
|
||||
./bin/acme route:list
|
||||
@@ -83,6 +83,8 @@ Both commands prompt for the password without echoing it. In a script, pipe it o
|
||||
|
||||
From the application directory, `summer admin:create --email admin@example.com --superuser` runs the same command on the built binary, prompt included.
|
||||
|
||||
When the admin is mounted and the `backend_users` table is empty, `serve` prints a warning at start-up naming `summer admin:create`.
|
||||
|
||||
## Queues and the scheduler
|
||||
|
||||
| Command | Arguments and flags | Purpose |
|
||||
|
||||
@@ -189,6 +189,8 @@ do("POST", "/api/blog/posts/7/comments", true)
|
||||
./bin/acme route:list
|
||||
```
|
||||
|
||||
The table is followed by a line saying whether the admin is mounted and under which prefix, for example `admin: mounted at /backend`.
|
||||
|
||||
`surf.BuildRouter` returns the same information to Go code through `surf.Router.Routes`:
|
||||
|
||||
```go src=modules/surf/example_test.go#ExampleBuildRouter
|
||||
|
||||
@@ -178,7 +178,7 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
```
|
||||
|
||||
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
|
||||
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
|
||||
|
||||
## API reference
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht
|
||||
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands.
|
||||
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -108,6 +108,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. |
|
||||
| `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. |
|
||||
| `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. |
|
||||
| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. |
|
||||
| `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. |
|
||||
| `surf.Use` | Builds a middleware name list for a group. |
|
||||
| `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. |
|
||||
@@ -162,8 +163,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. |
|
||||
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
|
||||
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"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"
|
||||
@@ -177,3 +179,57 @@ func (p prefixRoutePlugin) Routes(r pact.Router) error {
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// RouteListCommand builds the router the same way ServeCommand does, then
|
||||
// renders Routes() as a table instead of serving.
|
||||
// renders Routes() as a table instead of serving, followed by the admin
|
||||
// status line.
|
||||
func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "route:list",
|
||||
@@ -33,6 +34,11 @@ func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command
|
||||
})
|
||||
}
|
||||
out.Table([]string{"Method", "Pattern", "Plugin", "Middleware", "Raw"}, rows)
|
||||
if prefix, mounted := r.AdminPrefix(); mounted {
|
||||
out.Info("admin: mounted at " + prefix)
|
||||
} else {
|
||||
out.Info(adminOffText)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ type Router struct {
|
||||
uploadBytes int64
|
||||
built map[string]pact.Middleware
|
||||
app *backpack.App
|
||||
adminPrefix string
|
||||
adminMounted bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -556,6 +558,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if err := r.checkAdminPrefix(admin.Prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.adminPrefix = admin.Prefix
|
||||
r.adminMounted = true
|
||||
}
|
||||
for _, rt := range r.routes {
|
||||
if _, err := r.wrap(rt); err != nil {
|
||||
|
||||
@@ -10,6 +10,15 @@ type RouteInfo struct {
|
||||
Raw bool
|
||||
}
|
||||
|
||||
// AdminPrefix reports the cabana admin mount path set by BuildRouter. False
|
||||
// means no plugin registers an admin controller, so no admin routes exist.
|
||||
func (r *Router) AdminPrefix() (string, bool) {
|
||||
if r == nil || !r.adminMounted {
|
||||
return "", false
|
||||
}
|
||||
return r.adminPrefix, true
|
||||
}
|
||||
|
||||
// Routes returns a defensive copy of every registered route.
|
||||
func (r *Router) Routes() []RouteInfo {
|
||||
if r == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -13,11 +14,13 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/conga"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ServeCommand starts a signal-aware HTTP server on the assembled router.
|
||||
@@ -50,22 +53,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
|
||||
return err
|
||||
}
|
||||
defer bucket.Close()
|
||||
h, err := Assemble(app, plugins)
|
||||
r, err := BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := r.compile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
worker, err := conga.StartServeWorker(ctx, app, plugins)
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
if worker != nil {
|
||||
out.Info(fmt.Sprintf("job worker started on queues: %s", strings.Join(worker.Queues(), ", ")))
|
||||
}
|
||||
prefix, mounted := r.AdminPrefix()
|
||||
admins := adminsUnknown
|
||||
if mounted {
|
||||
admins = countAdmins(ctx, gdb)
|
||||
}
|
||||
printBanner(out, serveBanner(addr, prefix, mounted, admins))
|
||||
srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
out.Info(fmt.Sprintf("listening on %s", addr))
|
||||
errCh <- srv.ListenAndServe()
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
shutdownCtx := func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -121,3 +138,119 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
|
||||
}
|
||||
return bucket, nil
|
||||
}
|
||||
|
||||
// adminCreateCommand is the command the startup hints name for creating the
|
||||
// first backend administrator.
|
||||
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"
|
||||
|
||||
// adminOffText is the admin status line serve and route:list print when no
|
||||
// plugin registers an admin controller.
|
||||
const adminOffText = "admin: off — no plugin registers an admin controller"
|
||||
|
||||
// serveURL turns a listen address into a URL a browser can open. An empty or
|
||||
// unspecified host (0.0.0.0, ::) becomes 127.0.0.1, port 80 is left out and
|
||||
// an IPv6 host is bracketed. An address that does not parse gives "".
|
||||
func serveURL(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil || port == "" {
|
||||
return ""
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
} else if ip, err := netip.ParseAddr(host); err == nil && ip.IsUnspecified() {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
hostport := net.JoinHostPort(host, port)
|
||||
if port == "80" {
|
||||
hostport = host
|
||||
if strings.Contains(host, ":") {
|
||||
hostport = "[" + host + "]"
|
||||
}
|
||||
}
|
||||
return "http://" + hostport + "/"
|
||||
}
|
||||
|
||||
// adminPresence is what serve could learn about backend administrators.
|
||||
type adminPresence int
|
||||
|
||||
const (
|
||||
adminsUnknown adminPresence = iota
|
||||
adminsMissingTable
|
||||
adminsNone
|
||||
adminsPresent
|
||||
)
|
||||
|
||||
// countAdmins checks whether backend_users exists and holds a row. It is
|
||||
// bounded by a 2 second timeout and maps every failure to adminsUnknown, so
|
||||
// it never fails or blocks boot. HasTable queries information_schema, so a
|
||||
// missing table does not reach the GORM error log.
|
||||
func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence {
|
||||
if gdb == nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
db := gdb.WithContext(c)
|
||||
if !db.Migrator().HasTable(&cabana.BackendUser{}) {
|
||||
if c.Err() != nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
return adminsMissingTable
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&cabana.BackendUser{}).Count(&n).Error; err != nil {
|
||||
return adminsUnknown
|
||||
}
|
||||
if n == 0 {
|
||||
return adminsNone
|
||||
}
|
||||
return adminsPresent
|
||||
}
|
||||
|
||||
// bannerLine is one startup line; warn lines print with the warning symbol.
|
||||
type bannerLine struct {
|
||||
warn bool
|
||||
text string
|
||||
}
|
||||
|
||||
// serveBanner builds the lines serve prints once the port is bound: the
|
||||
// listen address with a browser URL, then the admin status.
|
||||
func serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence) []bannerLine {
|
||||
url := serveURL(addr)
|
||||
listen := "listening on " + addr
|
||||
if url != "" {
|
||||
listen += ", open " + url
|
||||
}
|
||||
lines := []bannerLine{{text: listen}}
|
||||
if !mounted {
|
||||
return append(lines,
|
||||
bannerLine{text: adminOffText},
|
||||
bannerLine{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"},
|
||||
)
|
||||
}
|
||||
if url != "" {
|
||||
lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix})
|
||||
} else {
|
||||
lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix})
|
||||
}
|
||||
switch admins {
|
||||
case adminsNone:
|
||||
lines = append(lines, bannerLine{warn: true, text: "no administrators yet; create one with " + adminCreateCommand})
|
||||
case adminsMissingTable:
|
||||
lines = append(lines, bannerLine{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand})
|
||||
case adminsUnknown:
|
||||
lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// printBanner writes banner lines as info or warning output.
|
||||
func printBanner(out bonfire.Output, lines []bannerLine) {
|
||||
for _, l := range lines {
|
||||
if l.warn {
|
||||
out.Warning(l.text)
|
||||
continue
|
||||
}
|
||||
out.Info(l.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gocloud.dev/blob"
|
||||
)
|
||||
@@ -51,3 +54,71 @@ func serveTestApp(t *testing.T, storageYAML string) *backpack.App {
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func TestServeURL(t *testing.T) {
|
||||
for _, tc := range []struct{ addr, want string }{
|
||||
{":8123", "http://127.0.0.1:8123/"},
|
||||
{"127.0.0.1:9000", "http://127.0.0.1:9000/"},
|
||||
{"[::1]:8080", "http://[::1]:8080/"},
|
||||
{"0.0.0.0:80", "http://127.0.0.1/"},
|
||||
{"[::]:8080", "http://127.0.0.1:8080/"},
|
||||
{"localhost:3000", "http://localhost:3000/"},
|
||||
{"8080", ""},
|
||||
{"", ""},
|
||||
{"127.0.0.1:", ""},
|
||||
} {
|
||||
if got := serveURL(tc.addr); got != tc.want {
|
||||
t.Errorf("serveURL(%q) = %q, want %q", tc.addr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeBanner(t *testing.T) {
|
||||
listen := bannerLine{text: "listening on :8123, open http://127.0.0.1:8123/"}
|
||||
mounted := bannerLine{text: "admin: http://127.0.0.1:8123/backend"}
|
||||
create := "summer admin:create --email you@example.com --superuser"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
addr string
|
||||
prefix string
|
||||
mounted bool
|
||||
admins adminPresence
|
||||
want []bannerLine
|
||||
}{
|
||||
{"present", ":8123", "/backend", true, adminsPresent, []bannerLine{listen, mounted}},
|
||||
{"none", ":8123", "/backend", true, adminsNone, []bannerLine{listen, mounted,
|
||||
{warn: true, text: "no administrators yet; create one with " + create}}},
|
||||
{"missing table", ":8123", "/backend", true, adminsMissingTable, []bannerLine{listen, mounted,
|
||||
{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + create}}},
|
||||
{"unknown", ":8123", "/backend", true, adminsUnknown, []bannerLine{listen, mounted,
|
||||
{text: "sign in with an administrator; " + create + " creates one"}}},
|
||||
{"off", ":8123", "", false, adminsUnknown, []bannerLine{listen,
|
||||
{text: "admin: off — no plugin registers an admin controller"},
|
||||
{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}},
|
||||
{"unparsable address", "8080", "/backend", true, adminsPresent, []bannerLine{
|
||||
{text: "listening on 8080"}, {text: "admin: mounted at /backend"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := serveBanner(tc.addr, tc.prefix, tc.mounted, tc.admins)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("serveBanner:\n got %#v\nwant %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintBanner(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||
printBanner(out, []bannerLine{{text: "plain"}, {warn: true, text: "careful"}})
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "ℹ plain") || !strings.Contains(got, "⚠ careful") {
|
||||
t.Fatalf("printBanner output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountAdminsNilDB(t *testing.T) {
|
||||
if got := countAdmins(t.Context(), nil); got != adminsUnknown {
|
||||
t.Fatalf("countAdmins(nil) = %v, want adminsUnknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user