package main_test import ( "encoding/json" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" ) // Binary path shared across tests — built once in TestMain to keep the // integration suite fast. var binPath string func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "fjmcp-broker-bin-*") if err != nil { fmt.Fprintln(os.Stderr, "integration: mkdir:", err) os.Exit(1) } defer os.RemoveAll(dir) binPath = filepath.Join(dir, "fjmcp-broker") build := exec.Command("go", "build", "-o", binPath, ".") build.Stderr = os.Stderr if err := build.Run(); err != nil { fmt.Fprintln(os.Stderr, "integration: build:", err) os.Exit(1) } os.Exit(m.Run()) } func TestBinary_Version(t *testing.T) { out, err := exec.Command(binPath, "--version").CombinedOutput() if err != nil { t.Fatalf("--version should exit 0: %v (output: %s)", err, out) } if !strings.Contains(string(out), "fjmcp-broker") { t.Errorf("version output missing binary name: %s", out) } } func TestBinary_MissingConfig_FailsWithClearError(t *testing.T) { cmd := exec.Command(binPath) // Reset env so the binary sees no ambient config. cmd.Env = []string{"PATH=" + os.Getenv("PATH")} out, err := cmd.CombinedOutput() if err == nil { t.Fatal("binary should exit nonzero with no config") } // Every required field should be mentioned, not just the first. for _, want := range []string{ "public-url", "forgejo-url", "forgejo-oauth-client-id", "forgejo-oauth-client-secret", } { if !strings.Contains(string(out), want) { t.Errorf("config error should mention %q, got:\n%s", want, out) } } } func TestBinary_Health_And_SigtermShutsDownCleanly(t *testing.T) { addr := freePort(t) storePath := filepath.Join(t.TempDir(), "broker.db") cmd := exec.Command(binPath, "--public-url", "http://localhost:1234", "--forgejo-url", "https://forgejo.example.com", "--forgejo-oauth-client-id", "test-id", "--forgejo-oauth-client-secret", "test-secret", "--store-path", storePath, "--listen", addr, ) cmd.Env = []string{"PATH=" + os.Getenv("PATH")} // Capture stderr so test failures surface the broker's logs. stderrR, stderrW := io.Pipe() cmd.Stderr = stderrW var capturedStderr strings.Builder go func() { _, _ = io.Copy(&capturedStderr, stderrR) }() if err := cmd.Start(); err != nil { t.Fatalf("start broker: %v", err) } // Ensure we always try to reap the process. defer func() { if cmd.ProcessState == nil && cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() } _ = stderrW.Close() }() waitListening(t, addr, 5*time.Second) resp, err := http.Get("http://" + addr + "/healthz") if err != nil { t.Fatalf("GET /healthz: %v", err) } body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("/healthz status = %d, want 200\nbody: %s", resp.StatusCode, body) } var h map[string]string if err := json.Unmarshal(body, &h); err != nil { t.Fatalf("/healthz body not JSON: %v\nbody: %s", err, body) } for _, k := range []string{"status", "version", "git_revision", "build_date", "store"} { if h[k] == "" { t.Errorf("/healthz missing field %q: %v", k, h) } } if h["status"] != "ok" { t.Errorf("/healthz status = %q, want ok (body: %s)", h["status"], body) } if h["store"] != "ok" { t.Errorf("/healthz store = %q, want ok (body: %s)", h["store"], body) } // Send SIGTERM and verify clean exit. start := time.Now() if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatalf("signal SIGTERM: %v", err) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: if err != nil { t.Errorf("broker exit with error: %v\nstderr:\n%s", err, capturedStderr.String()) } if elapsed := time.Since(start); elapsed > 2*time.Second { t.Errorf("shutdown took %s, want < 2s", elapsed) } case <-time.After(3 * time.Second): _ = cmd.Process.Kill() t.Fatal("broker did not exit within 3s of SIGTERM") } } // freePort returns a 127.0.0.1: the kernel assigned. The listener is // closed immediately, so there's a tiny race window before the broker // rebinds — acceptable on loopback. func freePort(t *testing.T) string { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("pick free port: %v", err) } addr := l.Addr().String() _ = l.Close() return addr } // waitListening polls the target until a TCP dial succeeds or the deadline // expires. Serves as a sync barrier for "the binary is up". func waitListening(t *testing.T, addr string, within time.Duration) { t.Helper() deadline := time.Now().Add(within) for time.Now().Before(deadline) { c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) if err == nil { _ = c.Close() return } time.Sleep(25 * time.Millisecond) } t.Fatalf("broker did not start listening on %s within %s", addr, within) }