e2e/webhook_test.go

v1.36.0
gitbay/e2e/webhook_test.go history · blame · raw

312 lines · 9796 bytes

  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, "",
116		"webhook", "add", "alice/proj", hookURL, "--secret", "s3cret"); 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}