package surf import ( "bytes" "context" "encoding/base64" "fmt" "net" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "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/diary" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" ) // lifecyclePlugin serves GET /probe/items/{id}. type lifecyclePlugin struct{} func (lifecyclePlugin) ID() string { return "acme.probe" } func (lifecyclePlugin) Requires() []string { return nil } func (lifecyclePlugin) Register(*backpack.App) error { return nil } func (lifecyclePlugin) Boot(*backpack.App) error { return nil } func (lifecyclePlugin) Routes(r pact.Router) error { r.Get("/probe/items/{id}", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) return nil } // syncBuffer is a bytes.Buffer safe for the log writes of serve's // goroutines and the test's reads. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // TestServeLifecycle runs serve against Postgres: it binds, logs the // startup summary and "listening on ", logs a request by its route // pattern under component http with its request id, and on cancellation // drains, logs the shutdown report and returns nil (exit 0). func TestServeLifecycle(t *testing.T) { if testing.Short() { t.Skip("requires testcontainers postgres") } ctx := t.Context() ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("surf"), postgres.WithUsername("surf"), postgres.WithPassword("surf-pass"), postgres.BasicWaitStrategies(), ) if err != nil { t.Fatalf("testcontainers postgres: %v", err) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatal(err) } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := ln.Addr().String() _ = ln.Close() dir := t.TempDir() files := map[string]string{ "app.yaml": "name: probe\n", "database.yaml": fmt.Sprintf("dsn: %q\n", dsn), "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n", "storage.yaml": fmt.Sprintf("uploads:\n bucket_url: %q\n", "file://"+t.TempDir()), } for name, body := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } // serve loads app.key at boot (Encrypted columns); a fixed test-only key. appKey := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)) cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development", "SUMMER_APP__KEY=" + appKey}}) if err != nil { t.Fatal(err) } app := backpack.New(cfg) plugins := []party.Plugin{lifecyclePlugin{}} // Migrate first so the in-process worker finds River's tables. sqlDB, gdb, err := lagoon.Open(ctx, dsn) if err != nil { t.Fatal(err) } if err := lagoon.Migrate(gdb, plugins); err != nil { t.Fatal(err) } _ = sqlDB.Close() var logs syncBuffer diary.Bootstrap(&logs, diary.Options{}) t.Cleanup(diary.Reset) runCtx, cancel := context.WithCancel(ctx) defer cancel() done := make(chan error, 1) var out bytes.Buffer go func() { done <- bonfire.Call(runCtx, []bonfire.Command{ServeCommand(app, plugins)}, "serve", []string{"--addr", addr}, &out) }() url := "http://" + addr + "/probe/items/secret-777" var resp *http.Response deadline := time.Now().Add(30 * time.Second) for { req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("X-Request-Id", "lifecycle-1") resp, err = http.DefaultClient.Do(req) if err == nil { break } select { case err := <-done: t.Fatalf("serve returned before listening: %v\n%s", err, logs.String()) default: } if time.Now().After(deadline) { t.Fatalf("serve did not listen on %s: %v\n%s", addr, err, logs.String()) } time.Sleep(100 * time.Millisecond) } _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("GET %s = %d", url, resp.StatusCode) } cancel() select { case err := <-done: if err != nil { t.Fatalf("serve returned %v after a graceful stop, want nil\n%s", err, logs.String()) } case <-time.After(30 * time.Second): t.Fatal("serve did not stop") } got := logs.String() for _, want := range []string{ "listening on " + addr, "msg=\"startup summary\"", "migrations_pending=0", "msg=\"shutdown report\"", "served=1", } { if !strings.Contains(got, want) { t.Errorf("logs lack %q:\n%s", want, got) } } var requests []string for _, line := range strings.Split(got, "\n") { if strings.Contains(line, "msg=\"http request\"") { requests = append(requests, line) } } if len(requests) != 1 { t.Fatalf("want one http request record, got %d:\n%s", len(requests), got) } for _, want := range []string{"component=http", "pattern=\"GET /probe/items/{id}\"", "request_id=lifecycle-1", "status=200"} { if !strings.Contains(requests[0], want) { t.Errorf("request record lacks %s: %s", want, requests[0]) } } if strings.Contains(got, "secret-777") { t.Fatalf("the request path reached the logs:\n%s", got) } if strings.Contains(got, appKey) { t.Fatalf("the app key reached the logs:\n%s", got) } if strings.Contains(got, "surf-pass") { t.Fatalf("the DSN password reached the logs:\n%s", got) } }