arche / internal/archesrv/webhooks_test.go

commit 154431fd
  1package archesrv
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"net/http/httptest"
  9	"strings"
 10	"testing"
 11	"time"
 12)
 13
 14func TestForgeServer_Webhook_FireAndRecord(t *testing.T) {
 15	s, ts := newTestServer(t)
 16	_, client := loginAsAdmin(t, s, ts)
 17	rec := setupRepoWithDisk(t, s, "myrepo", "private")
 18
 19	type delivery struct {
 20		body []byte
 21		sig  string
 22	}
 23	ch := make(chan delivery, 1)
 24	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 25		body, _ := io.ReadAll(r.Body)
 26		ch <- delivery{body, r.Header.Get("X-Arche-Signature")}
 27		w.WriteHeader(http.StatusOK)
 28	}))
 29	defer hookSrv.Close()
 30
 31	resp, err := client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{
 32		"url":    {hookSrv.URL},
 33		"secret": {"mysecret"},
 34		"events": {"push"},
 35	})
 36	if err != nil {
 37		t.Fatalf("POST webhook: %v", err)
 38	}
 39	resp.Body.Close()
 40
 41	s.db.FirePushWebhooks("myrepo", "admin", "main", "000", "aaa", nil)
 42
 43	var got delivery
 44	select {
 45	case got = <-ch:
 46	case <-time.After(3 * time.Second):
 47		t.Fatal("webhook not delivered within 3s")
 48	}
 49
 50	if !strings.HasPrefix(got.sig, "sha256=") {
 51		t.Errorf("signature header malformed: %q", got.sig)
 52	}
 53
 54	expectedSig := "sha256=" + computeHMAC("mysecret", got.body)
 55	if got.sig != expectedSig {
 56		t.Errorf("HMAC mismatch:\nwant %q\ngot  %q", expectedSig, got.sig)
 57	}
 58
 59	hooks, _ := s.db.ListWebhooks(rec.ID)
 60	if len(hooks) == 0 {
 61		t.Fatal("no webhooks found")
 62	}
 63	var ds []WebhookDelivery
 64	for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(30 * time.Millisecond) {
 65		ds, _ = s.db.ListDeliveries(hooks[0].ID)
 66		if len(ds) > 0 {
 67			break
 68		}
 69	}
 70	if len(ds) == 0 {
 71		t.Error("delivery should be recorded")
 72	}
 73}
 74
 75func TestForgeServer_Webhook_PushPayloadShape(t *testing.T) {
 76	s, ts := newTestServer(t)
 77	_, client := loginAsAdmin(t, s, ts)
 78	setupRepoWithDisk(t, s, "myrepo", "private")
 79
 80	ch := make(chan []byte, 1)
 81	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 82		body, _ := io.ReadAll(r.Body)
 83		ch <- body
 84		w.WriteHeader(http.StatusOK)
 85	}))
 86	defer hookSrv.Close()
 87
 88	resp, _ := client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{
 89		"url": {hookSrv.URL}, "events": {"push"},
 90	})
 91	resp.Body.Close()
 92
 93	s.db.FirePushWebhooks("myrepo", "alice", "main", "aabbcc", "ddeeff", nil)
 94
 95	var payloadBody []byte
 96	select {
 97	case payloadBody = <-ch:
 98	case <-time.After(3 * time.Second):
 99		t.Fatal("no payload delivered within 3s")
100	}
101
102	var p PushPayload
103	if err := json.Unmarshal(payloadBody, &p); err != nil {
104		t.Fatalf("unmarshal payload: %v", err)
105	}
106	if p.Repo != "myrepo" {
107		t.Errorf("Repo: want myrepo, got %q", p.Repo)
108	}
109	if p.Pusher != "alice" {
110		t.Errorf("Pusher: want alice, got %q", p.Pusher)
111	}
112	if p.Bookmark != "main" {
113		t.Errorf("Bookmark: want main, got %q", p.Bookmark)
114	}
115	if p.OldCommit != "aabbcc" {
116		t.Errorf("OldCommit: want aabbcc, got %q", p.OldCommit)
117	}
118}
119
120func TestForgeServer_Webhook_NoSecretStillDelivers(t *testing.T) {
121	s, ts := newTestServer(t)
122	_, client := loginAsAdmin(t, s, ts)
123	setupRepoWithDisk(t, s, "myrepo", "private")
124
125	ch := make(chan []byte, 1)
126	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127		body, _ := io.ReadAll(r.Body)
128		ch <- body
129		w.WriteHeader(http.StatusOK)
130	}))
131	defer hookSrv.Close()
132
133	resp, _ := client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{
134		"url":    {hookSrv.URL},
135		"secret": {""},
136		"events": {"push"},
137	})
138	resp.Body.Close()
139
140	s.db.FirePushWebhooks("myrepo", "admin", "main", "0", "1", nil)
141
142	select {
143	case <-ch:
144	case <-time.After(3 * time.Second):
145		t.Error("webhook with empty secret should still be delivered")
146	}
147}
148
149func TestForgeServer_Webhook_DeleteWebhook(t *testing.T) {
150	s, ts := newTestServer(t)
151	_, client := loginAsAdmin(t, s, ts)
152	rec := setupRepoWithDisk(t, s, "myrepo", "private")
153
154	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
155		w.WriteHeader(http.StatusOK)
156	}))
157	defer hookSrv.Close()
158
159	resp, _ := client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{
160		"url": {hookSrv.URL},
161	})
162	resp.Body.Close()
163
164	hooks, _ := s.db.ListWebhooks(rec.ID)
165	if len(hooks) == 0 {
166		t.Fatal("webhook not created")
167	}
168	hookID := hooks[0].ID
169
170	req, _ := http.NewRequest(http.MethodDelete,
171		fmt.Sprintf("%s/myrepo/settings/webhooks/%d", ts.URL, hookID), nil)
172	resp2, err := client.Do(req)
173	if err != nil {
174		t.Fatalf("DELETE webhook: %v", err)
175	}
176	resp2.Body.Close()
177	if resp2.StatusCode != http.StatusNoContent {
178		t.Errorf("DELETE webhook: want 204, got %d", resp2.StatusCode)
179	}
180
181	hooks2, _ := s.db.ListWebhooks(rec.ID)
182	if len(hooks2) != 0 {
183		t.Error("webhook should not exist after deletion")
184	}
185}
186
187func TestForgeServer_Webhook_ListPage(t *testing.T) {
188	s, ts := newTestServer(t)
189	_, client := loginAsAdmin(t, s, ts)
190	setupRepoWithDisk(t, s, "myrepo", "private")
191
192	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
193		w.WriteHeader(http.StatusOK)
194	}))
195	defer hookSrv.Close()
196
197	client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{"url": {hookSrv.URL}}) //nolint:errcheck
198
199	resp, err := client.Get(ts.URL + "/myrepo/settings/webhooks")
200	if err != nil {
201		t.Fatalf("GET: %v", err)
202	}
203	defer resp.Body.Close()
204	if resp.StatusCode != http.StatusOK {
205		t.Errorf("webhook list: want 200, got %d", resp.StatusCode)
206	}
207	body, _ := io.ReadAll(resp.Body)
208	if !strings.Contains(string(body), hookSrv.URL) {
209		t.Error("webhook list page should mention the webhook URL")
210	}
211}
212
213func TestForgeServer_Webhook_DeliveriesPage(t *testing.T) {
214	s, ts := newTestServer(t)
215	_, client := loginAsAdmin(t, s, ts)
216	rec := setupRepoWithDisk(t, s, "myrepo", "private")
217
218	ch := make(chan struct{}, 1)
219	hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
220		io.ReadAll(r.Body) //nolint:errcheck
221		ch <- struct{}{}
222		w.WriteHeader(http.StatusOK)
223	}))
224	defer hookSrv.Close()
225
226	client.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{"url": {hookSrv.URL}}) //nolint:errcheck
227	s.db.FirePushWebhooks("myrepo", "admin", "main", "0", "1", nil)
228
229	select {
230	case <-ch:
231	case <-time.After(3 * time.Second):
232		t.Fatal("webhook not delivered within 3s")
233	}
234
235	hooks, _ := s.db.ListWebhooks(rec.ID)
236	if len(hooks) == 0 {
237		t.Fatal("no webhook found")
238	}
239
240	resp, err := client.Get(fmt.Sprintf("%s/myrepo/settings/webhooks/%d/deliveries", ts.URL, hooks[0].ID))
241	if err != nil {
242		t.Fatalf("GET deliveries: %v", err)
243	}
244	defer resp.Body.Close()
245	if resp.StatusCode != http.StatusOK {
246		t.Errorf("deliveries page: want 200, got %d", resp.StatusCode)
247	}
248}
249
250func TestForgeServer_Webhook_NonAdminCannotCreate(t *testing.T) {
251	s, ts := newTestServer(t)
252	s.db.CreateUser("admin", "adminpass", true) //nolint:errcheck
253	setupRepoWithDisk(t, s, "myrepo", "private")
254
255	alice, _ := s.db.CreateUser("alice", "pass", false)
256	rec, _ := s.db.GetRepo("myrepo")
257	s.db.SetPermission(rec.ID, alice.ID, "write") //nolint:errcheck
258	aliceClient := loginAs(t, ts, "alice", "pass")
259
260	resp, err := aliceClient.PostForm(ts.URL+"/myrepo/settings/webhooks", map[string][]string{
261		"url": {"http://example.com/hook"},
262	})
263	if err != nil {
264		t.Fatalf("POST: %v", err)
265	}
266	resp.Body.Close()
267	if resp.StatusCode < 400 {
268		t.Errorf("non-admin should not create webhooks, got %d", resp.StatusCode)
269	}
270}