test(surf,conga,lagoon): summaries, reports, stats and checklist flags

- serve summary lines at -v and -q, records and HideOnTTY, DSN labels
- shutdown report: aborted requests fail the stop, a drained stop is nil
- request log: request_id cap, default on, off by config or env, never the
  path for matched, 404 and 405 routes, component http; route:list --json
- WorkerStats through runAttempt: done, failed, in flight, handed back
- queue:clear and lagoon's production question on a pseudo-terminal:
  no cancels with exit 1, --force, -n and other environments skip it
- Docker: PendingMigrations, migrate/rollback/deferred:purge --pretend,
  production runs off a terminal never read stdin, migrate:status --json
- key:generate --json and -q
This commit is contained in:
Jakub Zych
2026-10-21 19:38:18 +02:00
parent fd5286884e
commit 6720caebd3
7 changed files with 1363 additions and 0 deletions

View File

@@ -4,14 +4,19 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
@@ -413,3 +418,167 @@ func TestAccessLog(t *testing.T) {
t.Fatalf("direct records = %v", recs)
}
}
// sharePlugin serves token-bearing paths: GET /share/{token} and
// POST /invite/{token}.
type sharePlugin struct{}
func (sharePlugin) ID() string { return "acme.share" }
func (sharePlugin) Requires() []string { return nil }
func (sharePlugin) Register(*backpack.App) error { return nil }
func (sharePlugin) Boot(*backpack.App) error { return nil }
func (sharePlugin) Routes(r pact.Router) error {
r.Get("/share/{token}", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
r.Post("/invite/{token}", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
return nil
}
// diaryAccessRouter assembles plugin with httpYAML and no published
// logger, so the request log goes to the diary component http, set up on a
// console buffer with environ.
func diaryAccessRouter(t *testing.T, httpYAML string, plugin party.Plugin, environ ...string) (http.Handler, *syncBuffer) {
t.Helper()
app := backpack.New(writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n"+httpYAML))
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_LOG__DEFAULT=console"}, environ...)})
if err != nil {
t.Fatal(err)
}
var buf syncBuffer
t.Cleanup(diary.Reset)
if err := diary.Setup(cfg, diary.Options{Stderr: &buf, Root: t.TempDir(), Environ: []string{}}); err != nil {
t.Fatal(err)
}
return h, &buf
}
// TestAccessLogRequestIDCap: X-Request-Id is trimmed, cut to 128 bytes on
// a rune boundary and stripped of invalid UTF-8.
func TestAccessLogRequestIDCap(t *testing.T) {
h, buf := accessLogRouter(t, true)
cases := map[string]string{
strings.Repeat("a", 300): strings.Repeat("a", errorPageMaxRequestID),
"\t req-7 \t": "req-7",
strings.Repeat("a", 127) + "é" + "tail": strings.Repeat("a", 127),
"bad\xffid": "badid",
}
for header, want := range cases {
buf.Reset()
req := httptest.NewRequest(http.MethodGet, "/blog/posts/1", nil)
req.Header["X-Request-Id"] = []string{header}
h.ServeHTTP(httptest.NewRecorder(), req)
recs := accessRecords(t, buf)
if len(recs) != 1 || recs[0]["request_id"] != want {
t.Errorf("X-Request-Id %q: records %v, want request_id %q", header, recs, want)
}
if id, _ := recs[0]["request_id"].(string); len(id) > errorPageMaxRequestID {
t.Errorf("request_id is %d bytes", len(id))
}
}
buf.Reset()
req := httptest.NewRequest(http.MethodGet, "/blog/posts/1", nil)
req.Header.Set("X-Request-Id", " ")
h.ServeHTTP(httptest.NewRecorder(), req)
if recs := accessRecords(t, buf); len(recs) != 1 || recs[0]["request_id"] != nil {
t.Errorf("a blank X-Request-Id gives %v, want no request_id", recs)
}
}
// TestAccessLogDefaultOn: without http.access_log, and with it true, every
// request is logged once.
func TestAccessLogDefaultOn(t *testing.T) {
for name, yaml := range map[string]string{"unset": "", "true": "access_log: true\n"} {
h, buf := diaryAccessRouter(t, yaml, blogPostsPlugin{})
for range 3 {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil))
}
if n := strings.Count(buf.String(), `msg="http request"`); n != 3 {
t.Errorf("%s: %d request records, want 3:\n%s", name, n, buf.String())
}
}
}
// TestAccessLogDisabled: http.access_log false, also as the string an
// environment variable gives, turns the request log off.
func TestAccessLogDisabled(t *testing.T) {
h, buf := diaryAccessRouter(t, "access_log: false\n", blogPostsPlugin{})
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/nowhere", nil))
if strings.Contains(buf.String(), "http request") {
t.Errorf("access_log false still logged:\n%s", buf.String())
}
dir := t.TempDir()
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)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_HTTP__ACCESS_LOG=false"}})
if err != nil {
t.Fatal(err)
}
if accessLogEnabled(backpack.New(cfg)) {
t.Error("SUMMER_HTTP__ACCESS_LOG=false does not turn the request log off")
}
if !accessLogEnabled(nil) || !accessLogEnabled(backpack.New(nil)) {
t.Error("the request log is off without a config")
}
}
// TestAccessLogNeverLogsPath: token-bearing paths and queries never reach
// the request log, for a matched route, a 404 (trailing slash) and a 405
// (wrong method), on the default diary console.
func TestAccessLogNeverLogsPath(t *testing.T) {
h, buf := diaryAccessRouter(t, "", sharePlugin{})
cases := []struct {
method, target string
status int
pattern string
}{
{http.MethodGet, "/share/tok-secret-200?sig=query-secret", http.StatusOK, `pattern="GET /share/{token}"`},
{http.MethodGet, "/share/tok-secret-404/?sig=query-secret", http.StatusNotFound, `pattern=""`},
{http.MethodDelete, "/share/tok-secret-405?sig=query-secret", http.StatusMethodNotAllowed, `pattern=""`},
{http.MethodGet, "/invite/tok-secret-invite", http.StatusMethodNotAllowed, `pattern=""`},
{http.MethodPost, "/invite/tok-secret-post", http.StatusNoContent, `pattern="POST /invite/{token}"`},
}
for _, tc := range cases {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.target, nil))
if rec.Code != tc.status {
t.Fatalf("%s %s = %d, want %d", tc.method, tc.target, rec.Code, tc.status)
}
}
got := buf.String()
lines := strings.Split(strings.TrimSpace(got), "\n")
if len(lines) != len(cases) {
t.Fatalf("want %d request records, got %d:\n%s", len(cases), len(lines), got)
}
for i, tc := range cases {
if !strings.Contains(lines[i], tc.pattern) || !strings.Contains(lines[i], fmt.Sprintf("status=%d", tc.status)) {
t.Errorf("record %d = %q, want %s status=%d", i, lines[i], tc.pattern, tc.status)
}
}
for _, leak := range []string{"tok-secret", "query-secret", "sig=", "/share/tok", "/invite/tok"} {
if strings.Contains(got, leak) {
t.Errorf("the request log carries %q:\n%s", leak, got)
}
}
}
// TestAccessLogComponentHTTP: without a published logger the request log
// is the diary component http, so log.components.http: warn silences it.
func TestAccessLogComponentHTTP(t *testing.T) {
h, buf := diaryAccessRouter(t, "", blogPostsPlugin{})
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil))
if !strings.Contains(buf.String(), "component=http") || !strings.Contains(buf.String(), "level=INFO") {
t.Errorf("request record lacks component=http:\n%s", buf.String())
}
quiet, qbuf := diaryAccessRouter(t, "", blogPostsPlugin{}, "SUMMER_LOG__COMPONENTS__HTTP=warn")
quiet.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/blog/posts/7", nil))
if strings.Contains(qbuf.String(), "http request") {
t.Errorf("log.components.http warn still logged:\n%s", qbuf.String())
}
}