~bigbes/sr-ht-compare

ref: 4e7c6329e40c58e790e98774d456af93c2e56bc5 sr-ht-compare/cmd/comparesrht/main_test.go -rw-r--r-- 4.0 KiB
4e7c6329 — bigbes web: full-width split diff view with a rendered file tree 30 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
145
146
147
148
package main

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
	"testing"
	"time"

	"github.com/fernet/fernet-go"
)

// TestStartupSmoke builds the daemon, runs it against a synthesized minimal
// config.ini (fresh crypto keys, 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 globs the embedded hashed CSS),
// 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 with valid crypto keys and an empty repos root.
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		t.Fatalf("generate fernet key: %v", err)
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		t.Fatalf("generate webhook seed: %v", err)
	}

	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=sourcehut
environment=development

[webhooks]
private-key=%s

[compare.sr.ht]
origin=http://compare.example

[meta.sr.ht]
origin=http://meta.example

[git.sr.ht]
origin=http://git.example
repos=%s
`, fk.Encode(), base64.StdEncoding.EncodeToString(seed), 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
}