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}