krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: 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}