e2e/webhook_test.go
312 lines · 9799 bytes
6 symbols in this file
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 t.Parallel()
82 inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n")
83 // Restart the daemon with a fast retry base for the failure tests.
84 inst.proc.Process.Kill()
85 inst.proc.Wait()
86 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
87 inst.proc.Env = append(os.Environ(), "GITBAY_WEBHOOK_RETRY_BASE=500ms")
88 inst.proc.Stderr = os.Stderr
89 if err := inst.proc.Start(); err != nil {
90 t.Fatal(err)
91 }
92 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
93 deadline := time.Now().Add(10 * time.Second)
94 for {
95 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
96 if err == nil {
97 conn.Close()
98 break
99 }
100 if time.Now().After(deadline) {
101 t.Fatal("daemon did not restart")
102 }
103 time.Sleep(50 * time.Millisecond)
104 }
105
106 aliceKey := inst.newKey(t, "alice")
107 inst.admin(t, "admin", "user", "create", "alice",
108 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
109 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/proj"); code != 0 {
110 t.Fatalf("repo create: %s", errOut)
111 }
112
113 recv := startHookReceiver(t)
114 hookURL := "http://" + recv.addr + "/hook"
115 if _, errOut, code := inst.ssh(t, aliceKey, "s3cret\n",
116 "webhook", "add", "alice/proj", hookURL, "--secret", "-"); code != 0 {
117 t.Fatalf("webhook add: %s", errOut)
118 }
119
120 // An issue event arrives, signed and shaped.
121 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'hook me'"); code != 0 {
122 t.Fatal("issue create failed")
123 }
124 got := recv.waitN(t, 1)
125 h := got[0]
126 if h.event != "issue.created" || h.delivery == "" {
127 t.Fatalf("delivery headers: %+v", h)
128 }
129 mac := hmac.New(sha256.New, []byte("s3cret"))
130 mac.Write(h.body)
131 if h.signature != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
132 t.Fatalf("HMAC mismatch: %s", h.signature)
133 }
134 var p struct {
135 Event string `json:"event"`
136 Repo string `json:"repo"`
137 Actor string `json:"actor"`
138 Data struct {
139 Number int `json:"number"`
140 } `json:"data"`
141 }
142 if err := json.Unmarshal(h.body, &p); err != nil {
143 t.Fatalf("payload: %v\n%s", err, h.body)
144 }
145 if p.Repo != "alice/proj" || p.Actor != "alice" || p.Data.Number != 1 {
146 t.Fatalf("payload fields: %+v", p)
147 }
148
149 // Push events flow through the hook chain.
150 work := t.TempDir()
151 env := inst.gitEnv(aliceKey)
152 mustGit(t, work, env, "clone", inst.sshURL("alice/proj"), "w")
153 dir := work + "/w"
154 os.WriteFile(dir+"/f.txt", []byte("x\n"), 0o644)
155 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
156 mustGit(t, dir, env, "add", ".")
157 mustGit(t, dir, env, "commit", "-q", "-m", "push event")
158 mustGit(t, dir, env, "push", "-q", "origin", "main")
159 got = recv.waitN(t, 2)
160 push := got[1]
161 if push.event != "push" || !strings.Contains(string(push.body), `"ref":"refs/heads/main"`) {
162 t.Fatalf("push event: %s %s", push.event, push.body)
163 }
164
165 // Event filters: a hook subscribed to mr.created ignores issues.
166 recv2 := startHookReceiver(t)
167 if _, _, code := inst.ssh(t, aliceKey, "",
168 "webhook", "add", "alice/proj", "http://"+recv2.addr+"/", "--events", "mr.created"); code != 0 {
169 t.Fatal("filtered webhook add failed")
170 }
171 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/proj", "--title", "'no hook'"); code != 0 {
172 t.Fatal("issue 2 failed")
173 }
174 got = recv.waitN(t, 3) // unfiltered hook sees it
175 if got[2].event != "issue.created" {
176 t.Fatalf("third delivery: %s", got[2].event)
177 }
178 time.Sleep(500 * time.Millisecond)
179 recv2.mu.Lock()
180 if len(recv2.got) != 0 {
181 t.Fatalf("filtered hook received %d deliveries", len(recv2.got))
182 }
183 recv2.mu.Unlock()
184
185 // Retries: fail twice, then succeed; attempts recorded.
186 recv.mu.Lock()
187 recv.failNext = 2
188 recv.mu.Unlock()
189 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "close", "alice/proj", "1"); code != 0 {
190 t.Fatal("close failed")
191 }
192 got = recv.waitN(t, 4)
193 if got[3].event != "issue.closed" {
194 t.Fatalf("retried event: %s", got[3].event)
195 }
196 out, _, _ := inst.ssh(t, aliceKey, "", "webhook", "deliveries", "alice/proj", "--json")
197 if !strings.Contains(out, `"attempts":3`) {
198 t.Fatalf("retry attempts not recorded:\n%s", out)
199 }
200
201 // Dead-letter after max attempts, then manual redelivery revives it.
202 recv.mu.Lock()
203 recv.failNext = 99
204 recv.mu.Unlock()
205 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "reopen", "alice/proj", "1"); code != 0 {
206 t.Fatal("reopen failed")
207 }
208 var deadID string
209 deadlineDL := time.Now().Add(30 * time.Second)
210 for time.Now().Before(deadlineDL) {
211 out, _, _ = inst.ssh(t, aliceKey, "", "webhook", "deliveries", "alice/proj", "--json")
212 var envl struct {
213 Data []struct {
214 ID int64 `json:"id"`
215 Event string `json:"event"`
216 Status string `json:"status"`
217 } `json:"data"`
218 }
219 json.Unmarshal([]byte(out), &envl)
220 for _, d := range envl.Data {
221 if d.Event == "issue.open" && d.Status == "failed" {
222 deadID = fmt.Sprint(d.ID)
223 }
224 }
225 if deadID != "" {
226 break
227 }
228 time.Sleep(300 * time.Millisecond)
229 }
230 if deadID == "" {
231 t.Fatalf("delivery never dead-lettered:\n%s", out)
232 }
233 recv.mu.Lock()
234 recv.failNext = 0
235 prev := len(recv.got)
236 recv.mu.Unlock()
237 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "redeliver", "alice/proj", deadID); code != 0 {
238 t.Fatalf("redeliver: %s", errOut)
239 }
240 recv.waitN(t, prev+1)
241
242 // SSRF: on a default instance (allow_local off), local targets are
243 // rejected at add time. A refused value is exit 1 with the reason;
244 // exit 2 is for the shape of the command line (#187).
245 inst2 := startInstance(t)
246 k2 := inst2.newKey(t, "a2")
247 inst2.admin(t, "admin", "user", "create", "a2", "--key", k2+".pub")
248 if _, _, code := inst2.ssh(t, k2, "", "repo", "create", "a2/r"); code != 0 {
249 t.Fatal("repo create failed")
250 }
251 _, errOut, code := inst2.ssh(t, k2, "", "webhook", "add", "a2/r", "http://127.0.0.1:9/x")
252 if code != 1 || !strings.Contains(errOut, "SSRF") {
253 t.Fatalf("local webhook target accepted: exit %d, %s", code, errOut)
254 }
255 if _, _, code := inst2.ssh(t, k2, "", "webhook", "add", "a2/r", "ftp://example.com/x"); code != 1 {
256 t.Fatal("non-http scheme accepted")
257 }
258}
259
260// TestWebhookEventCoverage drives the mutations #112 found unrecorded and
261// asserts each reaches a subscriber. Half the forge's mutations recorded
262// nothing, so a webhook could be subscribed to them and never fire.
263func TestWebhookEventCoverage(t *testing.T) {
264 t.Parallel()
265 inst := startInstance(t)
266 aliceKey := inst.newKey(t, "alice")
267 bobKey := inst.newKey(t, "bob")
268 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
269 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
270 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
271 t.Fatalf("repo create: %s", errOut)
272 }
273 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
274 t.Fatal("grant failed")
275 }
276
277 // A name that is not an event is refused rather than silently never
278 // firing.
279 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "add", "alice/app",
280 "https://example.test/h", "--events", "issue.tagged"); code != 2 ||
281 !strings.Contains(errOut, "not an event this forge records") {
282 t.Fatalf("bad event name: exit %d, %s", code, errOut)
283 }
284
285 // Drive each newly recorded mutation.
286 steps := [][]string{
287 {"issue", "create", "alice/app", "--title", "'first'", "--body", "'b'"},
288 {"issue", "edit", "alice/app", "1", "--title", "'renamed'"},
289 {"issue", "label", "alice/app", "1", "--add", "bug"},
290 {"issue", "assign", "alice/app", "1", "--add", "bob"},
291 {"milestone", "create", "alice/app", "v1"},
292 {"issue", "milestone", "alice/app", "1", "v1"},
293 }
294 for _, s := range steps {
295 if _, errOut, code := inst.ssh(t, aliceKey, "", s...); code != 0 {
296 t.Fatalf("%v: %s", s, errOut)
297 }
298 }
299
300 out, errOut, code := inst.ssh(t, aliceKey, "", "feed", "--json")
301 if code != 0 {
302 t.Fatalf("feed: %s", errOut)
303 }
304 for _, kind := range []string{
305 "issue.created", "issue.edited", "issue.labeled",
306 "issue.assigned", "issue.milestoned",
307 } {
308 if !strings.Contains(out, `"`+kind+`"`) {
309 t.Errorf("%s was not recorded:\n%s", kind, out)
310 }
311 }
312}