~bigbes/sr-ht-compare

sr-ht-compare/cmd/comparesrht/main_test.go -rw-r--r-- 4.1 KiB
9720ccc2 — bigbes go.mod: take the shared libraries' current heads 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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
}