test(cabana,flare,dev,summer): json, reported errors and dev serve
- Docker: admin:token:create and list --json, -q keeps the token - flare generate and test-push failures print once and are Reported - summer dev starts serve with the forwarded globals, reports rebuilds, failures and restarts, and stops serve with SIGTERM returning nil - delegates carry two examples and say the global options pass through
This commit is contained in:
@@ -236,3 +236,55 @@ func TestNewAppCmdCancelSendsSIGTERM(t *testing.T) {
|
||||
t.Fatalf("child was not stopped with SIGTERM:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegateExamples: every delegate has two examples rendered after
|
||||
// summer in its help, and its long help says that flags and the global
|
||||
// options pass to the app binary; global options before or after the
|
||||
// command word reach the app binary.
|
||||
func TestDelegateExamples(t *testing.T) {
|
||||
got := captureAppRunner(t)
|
||||
for _, c := range delegateCommands() {
|
||||
if len(c.Examples) != 2 {
|
||||
t.Errorf("%s has %d examples, want 2", c.Name, len(c.Examples))
|
||||
}
|
||||
if !strings.Contains(c.Long, "global options (-v, -q, --ansi, --no-ansi, -n, --env)") || !strings.Contains(c.Long, "passed to the app binary unchanged") {
|
||||
t.Errorf("%s: long help does not say that global options pass through:\n%s", c.Name, c.Long)
|
||||
}
|
||||
out, err := runSummer(t, "help", c.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("help %s: %v", c.Name, err)
|
||||
}
|
||||
for _, e := range c.Examples {
|
||||
if !strings.HasPrefix(e.Command, c.Name) || e.Description == "" {
|
||||
t.Errorf("%s: example %+v does not start with the command or lacks a description", c.Name, e)
|
||||
}
|
||||
if !strings.Contains(out, " summer "+e.Command+"\n") || !strings.Contains(out, "# "+e.Description) {
|
||||
t.Errorf("help %s lacks the example %q:\n%s", c.Name, e.Command, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
if *got != nil {
|
||||
t.Fatalf("help ran the app binary with %q", *got)
|
||||
}
|
||||
if problems := bonfire.CheckDocs(delegateCommands()); len(problems) != 0 {
|
||||
t.Errorf("delegates lack documentation: %v", problems)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"-v", "migrate", "--pretend"}, []string{"migrate", "-v", "--pretend"}},
|
||||
{[]string{"migrate", "-qn"}, []string{"migrate", "-qn"}},
|
||||
{[]string{"--env", "staging", "queue:work"}, []string{"queue:work", "--env", "staging"}},
|
||||
{[]string{"--no-ansi", "serve", "--addr", ":8123"}, []string{"serve", "--no-ansi", "--addr", ":8123"}},
|
||||
} {
|
||||
got := captureAppRunner(t)
|
||||
if out, err := runSummer(t, tc.args...); err != nil {
|
||||
t.Fatalf("%q: %v\n%s", tc.args, err, out)
|
||||
}
|
||||
if !slices.Equal(*got, tc.want) {
|
||||
t.Errorf("%q: argv = %q, want %q", tc.args, *got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,3 +472,151 @@ func waitUntil(t *testing.T, name string, ok func() bool) {
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", name)
|
||||
}
|
||||
|
||||
// fakeServe writes an executable that records its arguments in
|
||||
// $DEV_ARGS_FILE and, on SIGTERM, writes "term" to $DEV_TERM_FILE and exits
|
||||
// 0, like serve's graceful stop.
|
||||
func fakeServe(t *testing.T, path string) (argsFile, termFile string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
argsFile, termFile = filepath.Join(dir, "args"), filepath.Join(dir, "term")
|
||||
t.Setenv("DEV_ARGS_FILE", argsFile)
|
||||
t.Setenv("DEV_TERM_FILE", termFile)
|
||||
script := "#!/bin/sh\n" +
|
||||
"printf '%s\\n' \"$*\" > \"$DEV_ARGS_FILE\"\n" +
|
||||
"trap 'echo term > \"$DEV_TERM_FILE\"; exit 0' TERM\n" +
|
||||
"while :; do sleep 0.05; done\n"
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return argsFile, termFile
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, _ := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// TestStartBinServe: summer dev starts bin/<binary> with "serve" first and
|
||||
// then the forwarded global options, through the real startBin.
|
||||
func TestStartBinServe(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "bin", "hello")
|
||||
argsFile, _ := fakeServe(t, bin)
|
||||
var out safeBuffer
|
||||
cmd, err := startBin(t.Context(), bin, []string{"serve", "-vv", "--env=staging"}, &out, &out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, "args written", func() bool { return readTrimmed(argsFile) != "" })
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
_ = cmd.Wait()
|
||||
if got := readTrimmed(argsFile); got != "serve -vv --env=staging" {
|
||||
t.Fatalf("child args = %q", got)
|
||||
}
|
||||
|
||||
// Through watch: the default Start runs serve with the globals summer
|
||||
// dev was given.
|
||||
dir := t.TempDir()
|
||||
writeWatchApp(t, dir)
|
||||
argsFile, _ = fakeServe(t, filepath.Join(dir, "bin", "hello"))
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
opts := Options{Debounce: 40 * time.Millisecond, Build: func(context.Context, string, io.Writer) error { return nil }}
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), []string{"-q", "--no-ansi"}, opts) }()
|
||||
waitUntil(t, "child started", func() bool { return readTrimmed(argsFile) != "" })
|
||||
if got := readTrimmed(argsFile); got != "serve -q --no-ansi" {
|
||||
t.Fatalf("watch started the child with %q", got)
|
||||
}
|
||||
if !strings.Contains(out.String(), "running bin/hello serve -q --no-ansi") {
|
||||
t.Errorf("watch line lacks the child command:\n%s", out.String())
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
// TestDevReport: the dev report counts successful and failed rebuilds and
|
||||
// restarts; a failed build keeps the running child.
|
||||
func TestDevReport(t *testing.T) {
|
||||
r := devReport{rebuilds: 2, failed: 1, restarts: 1, started: time.Now().Add(-90 * time.Second)}
|
||||
d := r.details()
|
||||
if d[0].Value != "3 (1 failed)" || !d[0].Warn || d[1].Value != "1" || d[2].Value != "1m30s" {
|
||||
t.Fatalf("details = %+v", d)
|
||||
}
|
||||
if clean := (devReport{rebuilds: 1, started: time.Now()}).details(); clean[0].Value != "1 (0 failed)" || clean[0].Warn {
|
||||
t.Fatalf("clean details = %+v", clean)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
writeWatchApp(t, dir)
|
||||
var out safeBuffer
|
||||
var builds, starts atomic.Int32
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
opts := Options{
|
||||
Debounce: 40 * time.Millisecond,
|
||||
Build: func(context.Context, string, io.Writer) error {
|
||||
if builds.Add(1) == 2 {
|
||||
return fmt.Errorf("syntax error")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Start: func(context.Context, string, []string, io.Writer, io.Writer) (*exec.Cmd, error) {
|
||||
starts.Add(1)
|
||||
cmd := exec.Command("sleep", "30")
|
||||
return cmd, cmd.Start()
|
||||
},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntil(t, "initial start", func() bool { return starts.Load() == 1 })
|
||||
write(t, filepath.Join(dir, "plugin.go"), "package p\n// broken\n")
|
||||
waitUntil(t, "failed build", func() bool { return builds.Load() == 2 })
|
||||
waitUntil(t, "build failure line", func() bool { return strings.Contains(out.String(), "Build failed: syntax error") })
|
||||
write(t, filepath.Join(dir, "plugin.go"), "package p\n// fixed\n")
|
||||
waitUntil(t, "restart", func() bool { return starts.Load() == 2 })
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("watch returned %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"Dev report", "Rebuilds 3 (1 failed)", "Restarts 1", "Uptime"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("dev report lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevGracefulNil: when summer dev is stopped it sends SIGTERM to the
|
||||
// running serve, which stops gracefully, prints the dev report and returns
|
||||
// nil (exit 0).
|
||||
func TestDevGracefulNil(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeWatchApp(t, dir)
|
||||
argsFile, termFile := fakeServe(t, filepath.Join(dir, "bin", "hello"))
|
||||
var out safeBuffer
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
opts := Options{Debounce: 40 * time.Millisecond, Build: func(context.Context, string, io.Writer) error { return nil }}
|
||||
go func() { done <- watch(ctx, dir, testOutput(&out), nil, opts) }()
|
||||
waitUntil(t, "child started", func() bool { return readTrimmed(argsFile) == "serve" })
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("watch returned %v after stop, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("watch did not stop")
|
||||
}
|
||||
if readTrimmed(termFile) != "term" {
|
||||
t.Fatal("serve did not get SIGTERM")
|
||||
}
|
||||
if !strings.Contains(out.String(), "Dev report") || !strings.Contains(out.String(), "Restarts 0") {
|
||||
t.Fatalf("no dev report:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
134
modules/cabana/token_json_test.go
Normal file
134
modules/cabana/token_json_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/party"
|
||||
)
|
||||
|
||||
// tokenJSONApp is a command app on the package database with the demo
|
||||
// plugin's scopes and one administrator.
|
||||
func tokenJSONApp(t *testing.T, login string) *backpack.App {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := commandApp(t, gdb)
|
||||
if err := app.Publish(party.Activated{tokenDemoPlugin{fsys: tokenDemoFS()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertAdmin(t, gdb, login, login+"@example.test", "secret-pass", true, false)
|
||||
return app
|
||||
}
|
||||
|
||||
// callToken runs one admin:token command through bonfire.Call, so the
|
||||
// global options and flags are parsed as on the command line.
|
||||
func callToken(t *testing.T, app *backpack.App, name string, args ...string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(context.Background(), cabana.RuntimeCommands(app), name, args, &buf); err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, buf.String())
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestTokenCreateJSON: admin:token:create --json prints one JSON object
|
||||
// with id, name, token, scopes and expires_at, under -q too.
|
||||
func TestTokenCreateJSON(t *testing.T) {
|
||||
app := tokenJSONApp(t, "json-create")
|
||||
for _, extra := range [][]string{nil, {"-q"}} {
|
||||
args := append([]string{"json-create", "--name", "agent", "--scope", "demo", "--expires", "2027-01-01", "--json"}, extra...)
|
||||
out := callToken(t, app, "admin:token:create", args...)
|
||||
var got struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(out))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&got); err != nil {
|
||||
t.Fatalf("%v: not the JSON object: %v\n%s", extra, err, out)
|
||||
}
|
||||
if got.ID == 0 || got.Name != "agent" || !wellFormedPlain(got.Token) || strings.Join(got.Scopes, ",") != "demo" ||
|
||||
got.ExpiresAt == nil || !strings.HasPrefix(*got.ExpiresAt, "2027-01-01") {
|
||||
t.Errorf("%v: created = %+v", extra, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenListJSON: admin:token:list --json prints a JSON array of every
|
||||
// active token with its hint and never the token itself; --revoked adds
|
||||
// revoked ones; an empty list is [].
|
||||
func TestTokenListJSON(t *testing.T) {
|
||||
app := tokenJSONApp(t, "json-list")
|
||||
empty := callToken(t, app, "admin:token:list", "json-list", "--json")
|
||||
if strings.TrimSpace(empty) != "[]" {
|
||||
t.Fatalf("empty list = %q, want []", empty)
|
||||
}
|
||||
plain := strings.TrimSpace(callToken(t, app, "admin:token:create", "json-list", "--name", "one", "--scope", "demo"))
|
||||
second := strings.TrimSpace(callToken(t, app, "admin:token:create", "json-list", "--name", "two", "--scope", "demo"))
|
||||
|
||||
out := callToken(t, app, "admin:token:list", "json-list", "--json")
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &list); err != nil {
|
||||
t.Fatalf("not a JSON array: %v\n%s", err, out)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("list = %v", list)
|
||||
}
|
||||
for _, key := range []string{"id", "login", "name", "scopes", "hint", "created_at", "last_used_at", "expires_at", "revoked_at"} {
|
||||
if _, ok := list[0][key]; !ok {
|
||||
t.Errorf("token entry lacks %s: %v", key, list[0])
|
||||
}
|
||||
}
|
||||
if list[0]["login"] != "json-list" || list[0]["name"] != "two" || list[0]["hint"] != "g15_…"+second[len(second)-4:] {
|
||||
t.Errorf("newest entry = %v", list[0])
|
||||
}
|
||||
if strings.Contains(out, plain) || strings.Contains(out, second) {
|
||||
t.Fatalf("the list leaks a plaintext token:\n%s", out)
|
||||
}
|
||||
|
||||
id := uint64(list[1]["id"].(float64))
|
||||
callToken(t, app, "admin:token:revoke", jsonNumber(id))
|
||||
if active := callToken(t, app, "admin:token:list", "json-list", "--json"); strings.Count(active, `"id":`) != 1 {
|
||||
t.Errorf("revoked token still listed:\n%s", active)
|
||||
}
|
||||
if all := callToken(t, app, "admin:token:list", "json-list", "--json", "--revoked", "-q"); strings.Count(all, `"id":`) != 2 || !strings.Contains(all, `"revoked_at": "`) {
|
||||
t.Errorf("--revoked -q list:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenCreateQuietKeepsToken: under -q admin:token:create still prints
|
||||
// the token, and only the token (data, not decoration).
|
||||
func TestTokenCreateQuietKeepsToken(t *testing.T) {
|
||||
app := tokenJSONApp(t, "json-quiet")
|
||||
out := callToken(t, app, "admin:token:create", "json-quiet", "--name", "agent", "--scope", "demo", "-q")
|
||||
if strings.Count(out, "\n") != 1 || !wellFormedPlain(strings.TrimSpace(out)) {
|
||||
t.Fatalf("-q output = %q, want one token line", out)
|
||||
}
|
||||
var list []struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(callToken(t, app, "admin:token:list", "json-quiet", "--json")), &list); err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v %v", list, err)
|
||||
}
|
||||
revoked := callToken(t, app, "admin:token:revoke", jsonNumber(list[0].ID), "-q")
|
||||
if revoked != "" {
|
||||
t.Errorf("-q revoke printed %q, want nothing", revoked)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(n uint64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -366,3 +367,58 @@ func TestTestPushCommand(t *testing.T) {
|
||||
noSecret(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlareReported: a failing generate or test-push prints its own error
|
||||
// line once and returns an error matching bonfire.ErrReported, so the
|
||||
// binary exits 1 without a second error block.
|
||||
func TestFlareReported(t *testing.T) {
|
||||
keys, err := GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withKeys, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
|
||||
withSource, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
|
||||
publishSource(t, withSource, &fakeSource{})
|
||||
cases := []struct {
|
||||
name string
|
||||
app *backpack.App
|
||||
args []string
|
||||
line string
|
||||
}{
|
||||
{"generate --update without a config", backpack.New(nil), []string{GenerateVAPIDKeysCommandName, "--update"}, "Failed to save the keys: no configuration loaded"},
|
||||
{"test-push with a bad user id", withKeys, []string{TestPushCommandName, "abc"}, "User abc not found"},
|
||||
{"test-push without a source", withKeys, []string{TestPushCommandName, "5"}, "no subscription source registered"},
|
||||
{"test-push for an unknown user", withSource, []string{TestPushCommandName, "9"}, "User 9 not found"},
|
||||
{"test-push without subscriptions", withSource, []string{TestPushCommandName, "5"}, "No push subscriptions found for this user"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out, errW bytes.Buffer
|
||||
root, err := bonfire.NewRootIO("acme", Commands(tc.app), strings.NewReader(""), &out, &errW)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := bonfire.Execute(context.Background(), root, tc.args); code != bonfire.ExitFailure {
|
||||
t.Fatalf("exit %d\n%s%s", code, out.String(), errW.String())
|
||||
}
|
||||
if n := strings.Count(errW.String(), tc.line); n != 1 {
|
||||
t.Errorf("error line %q printed %d times:\n%s", tc.line, n, errW.String())
|
||||
}
|
||||
if strings.Contains(errW.String(), "ERROR") || strings.Contains(out.String(), "ERROR") {
|
||||
t.Errorf("a reported failure printed an error block:\n%s%s", out.String(), errW.String())
|
||||
}
|
||||
|
||||
var direct bytes.Buffer
|
||||
var cmd bonfire.Command
|
||||
for _, c := range Commands(tc.app) {
|
||||
if c.Name == tc.args[0] {
|
||||
cmd = c
|
||||
}
|
||||
}
|
||||
err = bonfire.Call(context.Background(), []bonfire.Command{cmd}, cmd.Name, tc.args[1:], &direct)
|
||||
if !errors.Is(err, bonfire.ErrReported) {
|
||||
t.Errorf("error %v does not match bonfire.ErrReported", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user