package main
import (
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
)
// TestStartupSmoke builds the daemon, runs it against a synthesized minimal
// config.ini (ecore's test keyset, an empty repos root), waits for /healthz to
// answer 200, then sends SIGINT and asserts a clean (exit code 0) warm
// shutdown. It exercises the real startup path end to end: config validation,
// crypto init via server.New, web.New (which resolves the embedded hashed
// artefacts and parses every page template), the middleware chain, and
// core-go's signal-driven Run loop.
//
// server.New's Run installs its warm-shutdown handler on SIGINT (the SourceHut
// fleet convention); the deployed systemd unit sets KillSignal=SIGINT so
// `systemctl stop` triggers the same graceful path this test verifies.
func TestStartupSmoke(t *testing.T) {
if testing.Short() {
t.Skip("smoke test builds a binary; skipped in -short mode")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skipf("go toolchain not available: %v", err)
}
// Build the daemon from the current package directory.
binDir := t.TempDir()
bin := filepath.Join(binDir, "comparesrht")
build := exec.Command("go", "build", "-o", bin, ".")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
// Synthesize a config.ini around ecore's test keyset and an empty repos
// root. The keys are constants rather than generated ones because they
// secure nothing here — nothing outside this test ever sees them, and a
// constant cannot be malformed by accident, which matters for a subprocess
// whose only way of complaining is a log.Fatal on startup.
confDir := t.TempDir() // becomes the process cwd; LoadConfig finds config.ini here
reposDir := t.TempDir()
confBody := fmt.Sprintf(`[sr.ht]
network-key=%s
site-name=%s
environment=development
[webhooks]
private-key=%s
[diff.sr.ht]
origin=%s
[meta.sr.ht]
origin=%s
[git.sr.ht]
origin=%s
repos=%s
`, ecoretest.NetworkKey, ecoretest.SiteName, ecoretest.WebhookKey,
ecoretest.Origin(service), ecoretest.Origin("meta.sr.ht"),
ecoretest.Origin("git.sr.ht"), reposDir)
if err := os.WriteFile(filepath.Join(confDir, "config.ini"), []byte(confBody), 0o644); err != nil {
t.Fatalf("write config.ini: %v", err)
}
addr := freeAddr(t)
cmd := exec.Command(bin, "-b", addr)
cmd.Dir = confDir // so config.LoadConfig picks up ./config.ini
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start daemon: %v", err)
}
// Guarantee the child is reaped even if an assertion below fails early.
defer func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}()
healthz := "http://" + addr + "/healthz"
if !waitForOK(healthz, 10*time.Second) {
t.Fatalf("daemon did not become healthy at %s within timeout", healthz)
}
// Warm shutdown: core-go's Run listens for SIGINT.
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
t.Fatalf("signal daemon: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("daemon exited uncleanly after SIGINT: %v", err)
}
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("daemon did not shut down within 10s of SIGINT")
}
}
// freeAddr reserves and immediately releases a loopback TCP port, returning its
// host:port. The daemon binds with SO_REUSEPORT so the brief release window is
// harmless.
func freeAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
addr := l.Addr().String()
_ = l.Close()
return addr
}
// waitForOK polls url until it returns HTTP 200 or the deadline elapses.
func waitForOK(url string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: time.Second}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true
}
}
time.Sleep(100 * time.Millisecond)
}
return false
}