krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: e2e/webhook_test.go · raw
1package e2e
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net"
11 "net/http"
12 "os"
13 "os/exec"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18)
19
20// hookReceiver captures webhook deliveries and can be told to fail.
21type hookReceiver struct {
22 addr string
23 mu sync.Mutex
24 got []capturedHook
25 failNext int // respond 500 to this many requests
26}
27
28type capturedHook struct {
29 event string
30 delivery string
31 signature string
32 body []byte
33}
34
35func startHookReceiver(t *testing.T) *hookReceiver {
36 t.Helper()
37 ln, err := net.Listen("tcp", "127.0.0.1:0")
38 if err != nil {
39 t.Fatal(err)
40 }
41 t.Cleanup(func() { ln.Close() })
42 h := &hookReceiver{addr: ln.Addr().String()}
43 go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 body, _ := io.ReadAll(r.Body)
45 h.mu.Lock()
46 defer h.mu.Unlock()
47 if h.failNext > 0 {
48 h.failNext--
49 w.WriteHeader(500)
50 return
51 }
52 h.got = append(h.got, capturedHook{
53 event: r.Header.Get("X-Gitbay-Event"),
54 delivery: r.Header.Get("X-Gitbay-Delivery"),
55 signature: r.Header.Get("X-Gitbay-Signature-256"),
56 body: body,
57 })
58 w.WriteHeader(204)
59 }))
60 return h
61}
62
63func (h *hookReceiver) waitN(t *testing.T, n int) []capturedHook {
64 t.Helper()
65 deadline := time.Now().Add(15 * time.Second)
66 for time.Now().Before(deadline) {
67 h.mu.Lock()
68 if len(h.got) >= n {
69 out := append([]capturedHook(nil), h.got...)
70 h.mu.Unlock()
71 return out
72 }
73 h.mu.Unlock()
74 time.Sleep(100 * time.Millisecond)
75 }
76 t.Fatalf("only %d deliveries arrived, want %d", len(h.got), n)
77 return nil
78}
79
80func TestWebhooks(t *testing.T) {
81 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
82 // Restart the daemon with a fast retry base for the failure tests.
83 inst.proc.Process.Kill()
84 inst.proc.Wait()
85 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
86 inst.proc.Env = append(os.Environ(), "GITBAY_WEBHOOK_RETRY_BASE=500ms")
87 inst.proc.Stderr = os.Stderr
88 if err := inst.proc.Start(); err != nil {
89 t.Fatal(err)
90 }
91 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
92 deadline := time.Now().Add(10 * time.Second)
93 for {
94 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
95 if err == nil {
96 conn.Close()
97 break
98 }
99 if time.Now().After(deadline) {
100 t.Fatal("daemon did not restart")
101 }
102 time.Sleep(50 * time.Millisecond)
103 }
104
105 aliceKey := inst.newKey(t, "alice")
106 inst.admin(t, "admin", "user", "create", "alice",
107 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
108 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
109 t.Fatalf("repo create: %s", errOut)
110 }
111
112 recv := startHookReceiver(t)
113 hookURL := "http://" + recv.addr + "/hook"
114 if _, errOut, code := inst.ssh(t, aliceKey, "",
115 "webhook", "add", "alice/proj", hookURL, "--secret", "s3cret"); code != 0 {
116 t.Fatalf("webhook add: %s", errOut)
117 }
118
119 // An issue event arrives, signed and shaped.
120 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'hook me'"); code != 0 {
121 t.Fatal("issue create failed")
122 }
123 got := recv.waitN(t, 1)
124 h := got[0]
125 if h.event != "issue.created" || h.delivery == "" {
126 t.Fatalf("delivery headers: %+v", h)
127 }
128 mac := hmac.New(sha256.New, []byte("s3cret"))
129 mac.Write(h.body)
130 if h.signature != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
131 t.Fatalf("HMAC mismatch: %s", h.signature)
132 }
133 var p struct {
134 Event string `json:"event"`
135 Repo string `json:"repo"`
136 Actor string `json:"actor"`
137 Data struct {
138 Number int `json:"number"`
139 } `json:"data"`
140 }
141 if err := json.Unmarshal(h.body, &p); err != nil {
142 t.Fatalf("payload: %v\n%s", err, h.body)
143 }
144 if p.Repo != "alice/proj" || p.Actor != "alice" || p.Data.Number != 1 {
145 t.Fatalf("payload fields: %+v", p)
146 }
147
148 // Push events flow through the hook chain.
149 work := t.TempDir()
150 env := inst.gitEnv(aliceKey)
151 mustGit(t, work, env, "clone", inst.sshURL("alice/proj"), "w")
152 dir := work + "/w"
153 os.WriteFile(dir+"/f.txt", []byte("x\n"), 0o644)
154 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
155 mustGit(t, dir, env, "add", ".")
156 mustGit(t, dir, env, "commit", "-q", "-m", "push event")
157 mustGit(t, dir, env, "push", "-q", "origin", "main")
158 got = recv.waitN(t, 2)
159 push := got[1]
160 if push.event != "push" || !strings.Contains(string(push.body), `"ref":"refs/heads/main"`) {
161 t.Fatalf("push event: %s %s", push.event, push.body)
162 }
163
164 // Event filters: a hook subscribed to mr.created ignores issues.
165 recv2 := startHookReceiver(t)
166 if _, _, code := inst.ssh(t, aliceKey, "",
167 "webhook", "add", "alice/proj", "http://"+recv2.addr+"/", "--events", "mr.created"); code != 0 {
168 t.Fatal("filtered webhook add failed")
169 }
170 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'no hook'"); code != 0 {
171 t.Fatal("issue 2 failed")
172 }
173 got = recv.waitN(t, 3) // unfiltered hook sees it
174 if got[2].event != "issue.created" {
175 t.Fatalf("third delivery: %s", got[2].event)
176 }
177 time.Sleep(500 * time.Millisecond)
178 recv2.mu.Lock()
179 if len(recv2.got) != 0 {
180 t.Fatalf("filtered hook received %d deliveries", len(recv2.got))
181 }
182 recv2.mu.Unlock()
183
184 // Retries: fail twice, then succeed; attempts recorded.
185 recv.mu.Lock()
186 recv.failNext = 2
187 recv.mu.Unlock()
188 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "close", "alice/proj", "1"); code != 0 {
189 t.Fatal("close failed")
190 }
191 got = recv.waitN(t, 4)
192 if got[3].event != "issue.closed" {
193 t.Fatalf("retried event: %s", got[3].event)
194 }
195 out, _, _ := inst.ssh(t, aliceKey, "", "webhook", "deliveries", "alice/proj", "--json")
196 if !strings.Contains(out, `"attempts":3`) {
197 t.Fatalf("retry attempts not recorded:\n%s", out)
198 }
199
200 // Dead-letter after max attempts, then manual redelivery revives it.
201 recv.mu.Lock()
202 recv.failNext = 99
203 recv.mu.Unlock()
204 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 0 {
205 t.Fatal("reopen failed")
206 }
207 var deadID string
208 deadlineDL := time.Now().Add(30 * time.Second)
209 for time.Now().Before(deadlineDL) {
210 out, _, _ = inst.ssh(t, aliceKey, "", "webhook", "deliveries", "alice/proj", "--json")
211 var envl struct {
212 Data []struct {
213 ID int64 `json:"id"`
214 Event string `json:"event"`
215 Status string `json:"status"`
216 } `json:"data"`
217 }
218 json.Unmarshal([]byte(out), &envl)
219 for _, d := range envl.Data {
220 if d.Event == "issue.open" && d.Status == "failed" {
221 deadID = fmt.Sprint(d.ID)
222 }
223 }
224 if deadID != "" {
225 break
226 }
227 time.Sleep(300 * time.Millisecond)
228 }
229 if deadID == "" {
230 t.Fatalf("delivery never dead-lettered:\n%s", out)
231 }
232 recv.mu.Lock()
233 recv.failNext = 0
234 prev := len(recv.got)
235 recv.mu.Unlock()
236 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "redeliver", "alice/proj", deadID); code != 0 {
237 t.Fatalf("redeliver: %s", errOut)
238 }
239 recv.waitN(t, prev+1)
240
241 // SSRF: on a default instance (allow_local off), local targets are
242 // rejected at add time.
243 inst2 := startInstance(t)
244 k2 := inst2.newKey(t, "a2")
245 inst2.admin(t, "admin", "user", "create", "a2", "--key", k2+".pub")
246 if _, _, code := inst2.ssh(t, k2, "", "repo", "create", "a2/r"); code != 0 {
247 t.Fatal("repo create failed")
248 }
249 _, errOut, code := inst2.ssh(t, k2, "", "webhook", "add", "a2/r", "http://127.0.0.1:9/x")
250 if code != 2 || !strings.Contains(errOut, "SSRF") {
251 t.Fatalf("local webhook target accepted: exit %d, %s", code, errOut)
252 }
253 if _, _, code := inst2.ssh(t, k2, "", "webhook", "add", "a2/r", "ftp://example.com/x"); code != 2 {
254 t.Fatal("non-http scheme accepted")
255 }
256}