From 2baec56420c24a21513afdd79e1e5a825e97e6eb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 19:38:19 +0200 Subject: [PATCH] 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 --- cmd/summer/runtime_test.go | 52 +++++++++++ internal/dev/watch_test.go | 148 ++++++++++++++++++++++++++++++ modules/cabana/token_json_test.go | 134 +++++++++++++++++++++++++++ modules/flare/commands_test.go | 56 +++++++++++ 4 files changed, 390 insertions(+) create mode 100644 modules/cabana/token_json_test.go diff --git a/cmd/summer/runtime_test.go b/cmd/summer/runtime_test.go index 0ba4c81c..dd7c335e 100644 --- a/cmd/summer/runtime_test.go +++ b/cmd/summer/runtime_test.go @@ -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) + } + } +} diff --git a/internal/dev/watch_test.go b/internal/dev/watch_test.go index d73e84ec..5d3a4abc 100644 --- a/internal/dev/watch_test.go +++ b/internal/dev/watch_test.go @@ -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/ 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()) + } +} diff --git a/modules/cabana/token_json_test.go b/modules/cabana/token_json_test.go new file mode 100644 index 00000000..234dfe38 --- /dev/null +++ b/modules/cabana/token_json_test.go @@ -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) +} diff --git a/modules/flare/commands_test.go b/modules/flare/commands_test.go index 9d51bb01..b5b1eb49 100644 --- a/modules/flare/commands_test.go +++ b/modules/flare/commands_test.go @@ -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) + } + }) + } +}