eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// End-to-end tests for incoming webhooks (hooks.go + internal/hooks): the
4// admin's create flow, the sidecar's poll → deploy → ingest → encrypt → post
5// walk against the real router, and the gates around it all.
6
7import (
8 "bytes"
9 "encoding/json"
10 "fmt"
11 "net/http"
12 "net/http/httptest"
13 "strings"
14 "testing"
15
16 "lchat/internal/bot"
17 "lchat/internal/e2ee"
18 "lchat/internal/hooks"
19)
20
21// provisionHooksService mints the service identity's invite (hooks_service, as
22// add-hooks does) and claims it, returning the sidecar's session.
23func provisionHooksService(t *testing.T, tsURL string) *bot.Session {
24 t.Helper()
25 secret, err := e2ee.RandomSecret()
26 if err != nil {
27 t.Fatal(err)
28 }
29 inv, err := e2ee.DeriveInvite(secret)
30 if err != nil {
31 t.Fatal(err)
32 }
33 body, _ := json.Marshal(map[string]any{
34 "label": "Webhooks",
35 "id": inv.ID,
36 "claim_hash": inv.ClaimHash,
37 "agent": true,
38 "hooks_service": true,
39 })
40 req, _ := http.NewRequest("POST", tsURL+"/api/admin/key-invites", bytes.NewReader(body))
41 req.Header.Set("Authorization", "Bearer admintok")
42 req.Header.Set("Content-Type", "application/json")
43 resp, err := http.DefaultClient.Do(req)
44 if err != nil {
45 t.Fatal(err)
46 }
47 defer resp.Body.Close()
48 var out struct {
49 OK bool `json:"ok"`
50 }
51 if json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK {
52 t.Fatalf("mint hooks-service invite: status %d", resp.StatusCode)
53 }
54 sess, err := bot.Provision(tsURL, secret, "Webhooks")
55 if err != nil {
56 t.Fatalf("provision service identity: %v", err)
57 }
58 return sess
59}
60
61// mkAdminMember creates a joined admin member with a device token.
62func mkAdminMember(t *testing.T, s *Server, label string) (token, uid string) {
63 t.Helper()
64 tok := mintDeviceToken(t, s.db, label)
65 uid, ok := s.db.deviceTokenUser(tok)
66 if !ok {
67 t.Fatal("no uid for minted token")
68 }
69 if err := s.db.setUserRole(uid, "admin"); err != nil {
70 t.Fatal(err)
71 }
72 return tok, uid
73}
74
75// createWebhookAsAdmin walks the app's create flow: keypair, wrap the thread
76// key, POST /api/hooks, seal the definition to the service identity, PUT blobs.
77func createWebhookAsAdmin(t *testing.T, tsURL, adminBearer, threadID, threadKeyB64, servicePub, name, ns string) (id, secret string) {
78 t.Helper()
79 kp, err := e2ee.GenerateUserKeypair()
80 if err != nil {
81 t.Fatal(err)
82 }
83 wrapped, err := e2ee.WrapThreadKey(threadKeyB64, kp.PublicKey)
84 if err != nil {
85 t.Fatal(err)
86 }
87 body, _ := json.Marshal(map[string]string{
88 "name": name, "public_key": kp.PublicKey, "thread_id": threadID, "wrapped": wrapped,
89 })
90 req, _ := http.NewRequest("POST", tsURL+"/api/hooks", bytes.NewReader(body))
91 req.Header.Set("Authorization", "Bearer "+adminBearer)
92 resp, err := http.DefaultClient.Do(req)
93 if err != nil {
94 t.Fatal(err)
95 }
96 defer resp.Body.Close()
97 var created struct {
98 ID string `json:"id"`
99 UserID string `json:"user_id"`
100 Token string `json:"token"`
101 }
102 if resp.StatusCode != http.StatusOK {
103 t.Fatalf("POST /api/hooks: status %d", resp.StatusCode)
104 }
105 if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ID == "" || created.Token == "" {
106 t.Fatalf("create response: %+v err=%v", created, err)
107 }
108
109 secret, err = e2ee.RandomSecret()
110 if err != nil {
111 t.Fatal(err)
112 }
113 def, err := e2ee.SealForPub(servicePub, hooks.Definition{
114 V: 1, ID: created.ID, UserID: created.UserID, Name: name, NS: ns,
115 ThreadID: threadID, Secret: secret, Token: created.Token, Priv: kp.PrivateKey,
116 })
117 if err != nil {
118 t.Fatal(err)
119 }
120 blobs, _ := json.Marshal(map[string]string{"def_blob": def, "meta_blob": "opaque-meta"})
121 req, _ = http.NewRequest("PUT", tsURL+"/api/hooks/"+created.ID+"/blobs", bytes.NewReader(blobs))
122 req.Header.Set("Authorization", "Bearer "+adminBearer)
123 resp2, err := http.DefaultClient.Do(req)
124 if err != nil {
125 t.Fatal(err)
126 }
127 defer resp2.Body.Close()
128 if resp2.StatusCode != http.StatusOK {
129 t.Fatalf("PUT blobs: status %d", resp2.StatusCode)
130 }
131 return created.ID, secret
132}
133
134// lastEventInThread fetches and decrypts the newest message in a thread.
135func lastEventInThread(t *testing.T, tsURL, memberTok, threadID, threadKeyB64 string) map[string]any {
136 t.Helper()
137 resp, err := http.Get(tsURL + "/api/messages?token=" + memberTok + "&thread=" + threadID)
138 if err != nil {
139 t.Fatal(err)
140 }
141 defer resp.Body.Close()
142 var out struct {
143 Messages []struct {
144 Blob string `json:"blob"`
145 } `json:"messages"`
146 }
147 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
148 t.Fatal(err)
149 }
150 if len(out.Messages) == 0 {
151 t.Fatal("no messages in thread")
152 }
153 key, err := e2ee.ImportKey(threadKeyB64)
154 if err != nil {
155 t.Fatal(err)
156 }
157 var payload map[string]any
158 if err := e2ee.DecryptJSON(key, out.Messages[len(out.Messages)-1].Blob, &payload); err != nil {
159 t.Fatalf("decrypt event: %v", err)
160 }
161 return payload
162}
163
164func TestWebhookEndToEnd(t *testing.T) {
165 ts, s := newTestServer(t)
166 sess := provisionHooksService(t, ts.URL)
167
168 // The service identity is discoverable (the seal target for creation).
169 resp, err := http.Get(ts.URL + "/api/hooks/service?token=" + sess.Token)
170 if err != nil {
171 t.Fatal(err)
172 }
173 var svc struct {
174 UserID string `json:"user_id"`
175 PublicKey string `json:"public_key"`
176 }
177 json.NewDecoder(resp.Body).Decode(&svc)
178 resp.Body.Close()
179 if svc.UserID == "" || svc.PublicKey != sess.UserPub {
180 t.Fatalf("hooks service: %+v (want the provisioned identity)", svc)
181 }
182
183 // An admin member with a group thread (the webhook's target).
184 adminTok, adminUID := mkAdminMember(t, s, "Paula")
185 threadKey, _ := e2ee.GenerateKey()
186 threadKeyB64 := e2ee.B64uEncode(threadKey)
187 body := `{"token":"` + adminTok + `","kind":"group","members":[{"user_id":"` + adminUID + `","wrapped":"wa"}]}`
188 tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body))
189 if err != nil {
190 t.Fatal(err)
191 }
192 var tout struct {
193 ThreadID string `json:"thread_id"`
194 }
195 json.NewDecoder(tresp.Body).Decode(&tout)
196 tresp.Body.Close()
197 if tout.ThreadID == "" {
198 t.Fatal("no thread id")
199 }
200
201 id, secret := createWebhookAsAdmin(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64, svc.PublicKey, "CI runs", "github")
202
203 // The webhook identity appears in the thread and on the roster as an agent.
204 wh, exists, _ := s.db.getWebhook(id)
205 if !exists || !s.db.isAgent(wh.UserID) || !s.db.threadMember(tout.ThreadID, wh.UserID) {
206 t.Fatalf("webhook identity not fully materialized: exists=%v", exists)
207 }
208
209 // Sidecar: poll → route live + deployed acked.
210 sc := hooks.New(ts.URL, sess, t.Logf)
211 if err := sc.PollOnce(); err != nil {
212 t.Fatalf("poll: %v", err)
213 }
214 if sc.Routes() != 1 {
215 t.Fatalf("routes = %d, want 1", sc.Routes())
216 }
217 if wh, _, _ := s.db.getWebhook(id); wh.DeployedAt == 0 {
218 t.Error("deployed_at not acked")
219 }
220
221 ingest := httptest.NewServer(sc.Handler())
222 defer ingest.Close()
223
224 // Deliver: an allowlisted header rides in, Authorization must not.
225 req, _ := http.NewRequest("POST", ingest.URL+"/h/"+secret, strings.NewReader(`{"action":"completed"}`))
226 req.Header.Set("Content-Type", "application/json")
227 req.Header.Set("X-GitHub-Event", "workflow_run")
228 req.Header.Set("Authorization", "Bearer sender-secret")
229 dresp, err := http.DefaultClient.Do(req)
230 if err != nil {
231 t.Fatal(err)
232 }
233 dresp.Body.Close()
234 if dresp.StatusCode != http.StatusOK {
235 t.Fatalf("delivery: status %d", dresp.StatusCode)
236 }
237
238 event := lastEventInThread(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64)
239 if event["type"] != "webhooks.github" {
240 t.Errorf("type = %v, want webhooks.github", event["type"])
241 }
242 if event["name"] != "CI runs" || event["uid"] != wh.UserID {
243 t.Errorf("attribution: name=%v uid=%v", event["name"], event["uid"])
244 }
245 reqPart, _ := event["req"].(map[string]any)
246 if reqPart == nil {
247 t.Fatalf("no req in event: %v", event)
248 }
249 if reqPart["method"] != "POST" || reqPart["body"] != `{"action":"completed"}` {
250 t.Errorf("req = %v", reqPart)
251 }
252 headers, _ := reqPart["headers"].(map[string]any)
253 if headers["X-Github-Event"] != "workflow_run" {
254 t.Errorf("allowlisted header missing: %v", headers)
255 }
256 for k := range headers {
257 if strings.EqualFold(k, "Authorization") {
258 t.Error("Authorization header captured into the event")
259 }
260 }
261
262 // Ingest hygiene: unknown secret 404s, oversize 413s.
263 if r, _ := http.Post(ingest.URL+"/h/nope", "text/plain", strings.NewReader("x")); r.StatusCode != http.StatusNotFound {
264 t.Errorf("unknown secret: status %d, want 404", r.StatusCode)
265 }
266 if r, _ := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader(strings.Repeat("a", 41*1024))); r.StatusCode != http.StatusRequestEntityTooLarge {
267 t.Errorf("oversize body: status %d, want 413", r.StatusCode)
268 }
269
270 // Delete retires the whole identity; the next poll drops the route and the
271 // URL dies.
272 dreq, _ := http.NewRequest("DELETE", ts.URL+"/api/hooks/"+id, nil)
273 dreq.Header.Set("Authorization", "Bearer "+adminTok)
274 delResp, err := http.DefaultClient.Do(dreq)
275 if err != nil {
276 t.Fatal(err)
277 }
278 delResp.Body.Close()
279 if delResp.StatusCode != http.StatusOK {
280 t.Fatalf("delete: status %d", delResp.StatusCode)
281 }
282 if s.db.userExists(wh.UserID) {
283 t.Error("webhook user survived deletion")
284 }
285 if err := sc.PollOnce(); err != nil {
286 t.Fatalf("poll after delete: %v", err)
287 }
288 if sc.Routes() != 0 {
289 t.Errorf("routes after delete = %d, want 0", sc.Routes())
290 }
291 if r, _ := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader("x")); r.StatusCode != http.StatusNotFound {
292 t.Errorf("deleted webhook still ingesting: status %d", r.StatusCode)
293 }
294}
295
296func TestWebhookSpaceThread(t *testing.T) {
297 ts, s := newTestServer(t)
298 sess := provisionHooksService(t, ts.URL)
299 adminTok, _ := mkAdminMember(t, s, "Paula")
300
301 // The space thread exists with an open_key only members can unwrap; the
302 // webhook gets the SAME thread key wrapped to its own pubkey instead — the
303 // explicit agent opt-in.
304 spaceID, _, created, err := s.db.createSpaceThread("opaque-open-key")
305 if err != nil || !created {
306 t.Fatalf("createSpaceThread: created=%v err=%v", created, err)
307 }
308 threadKey, _ := e2ee.GenerateKey()
309 threadKeyB64 := e2ee.B64uEncode(threadKey)
310
311 id, secret := createWebhookAsAdmin(t, ts.URL, adminTok, spaceID, threadKeyB64, sess.UserPub, "Deploy feed", "github")
312
313 sc := hooks.New(ts.URL, sess, t.Logf)
314 if err := sc.PollOnce(); err != nil {
315 t.Fatalf("poll: %v", err)
316 }
317 ingest := httptest.NewServer(sc.Handler())
318 defer ingest.Close()
319
320 if r, _ := http.Post(ingest.URL+"/h/"+secret, "application/json", strings.NewReader(`{"run":1}`)); r.StatusCode != http.StatusOK {
321 t.Fatalf("space delivery: status %d", r.StatusCode)
322 }
323 event := lastEventInThread(t, ts.URL, adminTok, spaceID, threadKeyB64)
324 if event["type"] != "webhooks.github" {
325 t.Errorf("type = %v", event["type"])
326 }
327 _ = id
328}
329
330func TestWebhookGates(t *testing.T) {
331 ts, s := newTestServer(t)
332
333 // A plain member can neither create nor list webhooks.
334 memberTok := mintDeviceToken(t, s.db, "Mallory")
335 body, _ := json.Marshal(map[string]string{"name": "x", "public_key": "p", "thread_id": "t", "wrapped": "w"})
336 req, _ := http.NewRequest("POST", ts.URL+"/api/hooks", bytes.NewReader(body))
337 req.Header.Set("Authorization", "Bearer "+memberTok)
338 resp, err := http.DefaultClient.Do(req)
339 if err != nil {
340 t.Fatal(err)
341 }
342 resp.Body.Close()
343 if resp.StatusCode != http.StatusUnauthorized {
344 t.Errorf("member create: status %d, want 401", resp.StatusCode)
345 }
346
347 // The defs poll needs an agent token — a person's token is refused.
348 r, err := http.Get(ts.URL + "/api/hooks/defs?token=" + memberTok)
349 if err != nil {
350 t.Fatal(err)
351 }
352 r.Body.Close()
353 if r.StatusCode != http.StatusForbidden {
354 t.Errorf("member defs poll: status %d, want 403", r.StatusCode)
355 }
356
357 // Creating into a DM is refused even for an admin (webhooks feed groups and
358 // the space thread only).
359 adminTok, adminUID := mkAdminMember(t, s, "Paula")
360 dmBody := `{"token":"` + adminTok + `","kind":"dm","members":[{"user_id":"` + adminUID + `","wrapped":"wa"},{"user_id":"` + mustUID(t, s, memberTok) + `","wrapped":"wb"}]}`
361 tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(dmBody))
362 if err != nil {
363 t.Fatal(err)
364 }
365 var tout struct {
366 ThreadID string `json:"thread_id"`
367 }
368 json.NewDecoder(tresp.Body).Decode(&tout)
369 tresp.Body.Close()
370 body, _ = json.Marshal(map[string]string{"name": "x", "public_key": "p", "thread_id": tout.ThreadID, "wrapped": "w"})
371 req, _ = http.NewRequest("POST", ts.URL+"/api/hooks", bytes.NewReader(body))
372 req.Header.Set("Authorization", "Bearer "+adminTok)
373 resp, err = http.DefaultClient.Do(req)
374 if err != nil {
375 t.Fatal(err)
376 }
377 resp.Body.Close()
378 if resp.StatusCode != http.StatusBadRequest {
379 t.Errorf("create into DM: status %d, want 400", resp.StatusCode)
380 }
381}
382
383// TestWebhookIngestBaseOverride: LCHAT_HOOKS_BASE (dev: the sidecar on a bare
384// local port) is echoed as ingest_base on the service endpoint; unset — the
385// production default — omits it so clients derive https://in.<host>.
386func TestWebhookIngestBaseOverride(t *testing.T) {
387 ts, s := newTestServer(t)
388 sess := provisionHooksService(t, ts.URL)
389
390 get := func() map[string]any {
391 t.Helper()
392 resp, err := http.Get(ts.URL + "/api/hooks/service?token=" + sess.Token)
393 if err != nil {
394 t.Fatal(err)
395 }
396 defer resp.Body.Close()
397 var out map[string]any
398 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
399 t.Fatal(err)
400 }
401 return out
402 }
403
404 if out := get(); out["ingest_base"] != nil {
405 t.Errorf("ingest_base = %v with no override, want absent", out["ingest_base"])
406 }
407 s.hooksBase = "http://127.0.0.1:9999"
408 if out := get(); out["ingest_base"] != "http://127.0.0.1:9999" {
409 t.Errorf("ingest_base = %v, want the override", out["ingest_base"])
410 }
411}
412
413// TestWebhookRateLimit drains one webhook's bucket and expects 429 — the
414// sidecar-side dial; the server's message brake backstops it independently.
415func TestWebhookRateLimit(t *testing.T) {
416 ts, s := newTestServer(t)
417 sess := provisionHooksService(t, ts.URL)
418 adminTok, adminUID := mkAdminMember(t, s, "Paula")
419 threadKey, _ := e2ee.GenerateKey()
420 threadKeyB64 := e2ee.B64uEncode(threadKey)
421 body := `{"token":"` + adminTok + `","kind":"group","members":[{"user_id":"` + adminUID + `","wrapped":"wa"}]}`
422 tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body))
423 if err != nil {
424 t.Fatal(err)
425 }
426 var tout struct {
427 ThreadID string `json:"thread_id"`
428 }
429 json.NewDecoder(tresp.Body).Decode(&tout)
430 tresp.Body.Close()
431
432 _, secret := createWebhookAsAdmin(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64, sess.UserPub, "Chatty", "github")
433 sc := hooks.New(ts.URL, sess, t.Logf)
434 if err := sc.PollOnce(); err != nil {
435 t.Fatal(err)
436 }
437 ingest := httptest.NewServer(sc.Handler())
438 defer ingest.Close()
439
440 limited := false
441 for i := 0; i < 100; i++ {
442 r, err := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader(fmt.Sprintf("n=%d", i)))
443 if err != nil {
444 t.Fatal(err)
445 }
446 r.Body.Close()
447 if r.StatusCode == http.StatusTooManyRequests {
448 limited = true
449 break
450 }
451 }
452 if !limited {
453 t.Error("100 rapid deliveries never rate-limited")
454 }
455}
456