package main // End-to-end tests for incoming webhooks (hooks.go + internal/hooks): the // admin's create flow, the sidecar's poll → deploy → ingest → encrypt → post // walk against the real router, and the gates around it all. import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "lchat/internal/bot" "lchat/internal/e2ee" "lchat/internal/hooks" ) // provisionHooksService mints the service identity's invite (hooks_service, as // add-hooks does) and claims it, returning the sidecar's session. func provisionHooksService(t *testing.T, tsURL string) *bot.Session { t.Helper() secret, err := e2ee.RandomSecret() if err != nil { t.Fatal(err) } inv, err := e2ee.DeriveInvite(secret) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{ "label": "Webhooks", "id": inv.ID, "claim_hash": inv.ClaimHash, "agent": true, "hooks_service": true, }) req, _ := http.NewRequest("POST", tsURL+"/api/admin/key-invites", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { OK bool `json:"ok"` } if json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK { t.Fatalf("mint hooks-service invite: status %d", resp.StatusCode) } sess, err := bot.Provision(tsURL, secret, "Webhooks") if err != nil { t.Fatalf("provision service identity: %v", err) } return sess } // mkAdminMember creates a joined admin member with a device token. func mkAdminMember(t *testing.T, s *Server, label string) (token, uid string) { t.Helper() tok := mintDeviceToken(t, s.db, label) uid, ok := s.db.deviceTokenUser(tok) if !ok { t.Fatal("no uid for minted token") } if err := s.db.setUserRole(uid, "admin"); err != nil { t.Fatal(err) } return tok, uid } // createWebhookAsAdmin walks the app's create flow: keypair, wrap the thread // key, POST /api/hooks, seal the definition to the service identity, PUT blobs. func createWebhookAsAdmin(t *testing.T, tsURL, adminBearer, threadID, threadKeyB64, servicePub, name, ns string) (id, secret string) { t.Helper() kp, err := e2ee.GenerateUserKeypair() if err != nil { t.Fatal(err) } wrapped, err := e2ee.WrapThreadKey(threadKeyB64, kp.PublicKey) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{ "name": name, "public_key": kp.PublicKey, "thread_id": threadID, "wrapped": wrapped, }) req, _ := http.NewRequest("POST", tsURL+"/api/hooks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+adminBearer) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var created struct { ID string `json:"id"` UserID string `json:"user_id"` Token string `json:"token"` } if resp.StatusCode != http.StatusOK { t.Fatalf("POST /api/hooks: status %d", resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ID == "" || created.Token == "" { t.Fatalf("create response: %+v err=%v", created, err) } secret, err = e2ee.RandomSecret() if err != nil { t.Fatal(err) } def, err := e2ee.SealForPub(servicePub, hooks.Definition{ V: 1, ID: created.ID, UserID: created.UserID, Name: name, NS: ns, ThreadID: threadID, Secret: secret, Token: created.Token, Priv: kp.PrivateKey, }) if err != nil { t.Fatal(err) } blobs, _ := json.Marshal(map[string]string{"def_blob": def, "meta_blob": "opaque-meta"}) req, _ = http.NewRequest("PUT", tsURL+"/api/hooks/"+created.ID+"/blobs", bytes.NewReader(blobs)) req.Header.Set("Authorization", "Bearer "+adminBearer) resp2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp2.Body.Close() if resp2.StatusCode != http.StatusOK { t.Fatalf("PUT blobs: status %d", resp2.StatusCode) } return created.ID, secret } // lastEventInThread fetches and decrypts the newest message in a thread. func lastEventInThread(t *testing.T, tsURL, memberTok, threadID, threadKeyB64 string) map[string]any { t.Helper() resp, err := http.Get(tsURL + "/api/messages?token=" + memberTok + "&thread=" + threadID) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { Messages []struct { Blob string `json:"blob"` } `json:"messages"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } if len(out.Messages) == 0 { t.Fatal("no messages in thread") } key, err := e2ee.ImportKey(threadKeyB64) if err != nil { t.Fatal(err) } var payload map[string]any if err := e2ee.DecryptJSON(key, out.Messages[len(out.Messages)-1].Blob, &payload); err != nil { t.Fatalf("decrypt event: %v", err) } return payload } func TestWebhookEndToEnd(t *testing.T) { ts, s := newTestServer(t) sess := provisionHooksService(t, ts.URL) // The service identity is discoverable (the seal target for creation). resp, err := http.Get(ts.URL + "/api/hooks/service?token=" + sess.Token) if err != nil { t.Fatal(err) } var svc struct { UserID string `json:"user_id"` PublicKey string `json:"public_key"` } json.NewDecoder(resp.Body).Decode(&svc) resp.Body.Close() if svc.UserID == "" || svc.PublicKey != sess.UserPub { t.Fatalf("hooks service: %+v (want the provisioned identity)", svc) } // An admin member with a group thread (the webhook's target). adminTok, adminUID := mkAdminMember(t, s, "Paula") threadKey, _ := e2ee.GenerateKey() threadKeyB64 := e2ee.B64uEncode(threadKey) body := `{"token":"` + adminTok + `","kind":"group","members":[{"user_id":"` + adminUID + `","wrapped":"wa"}]}` tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body)) if err != nil { t.Fatal(err) } var tout struct { ThreadID string `json:"thread_id"` } json.NewDecoder(tresp.Body).Decode(&tout) tresp.Body.Close() if tout.ThreadID == "" { t.Fatal("no thread id") } id, secret := createWebhookAsAdmin(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64, svc.PublicKey, "CI runs", "github") // The webhook identity appears in the thread and on the roster as an agent. wh, exists, _ := s.db.getWebhook(id) if !exists || !s.db.isAgent(wh.UserID) || !s.db.threadMember(tout.ThreadID, wh.UserID) { t.Fatalf("webhook identity not fully materialized: exists=%v", exists) } // Sidecar: poll → route live + deployed acked. sc := hooks.New(ts.URL, sess, t.Logf) if err := sc.PollOnce(); err != nil { t.Fatalf("poll: %v", err) } if sc.Routes() != 1 { t.Fatalf("routes = %d, want 1", sc.Routes()) } if wh, _, _ := s.db.getWebhook(id); wh.DeployedAt == 0 { t.Error("deployed_at not acked") } ingest := httptest.NewServer(sc.Handler()) defer ingest.Close() // Deliver: an allowlisted header rides in, Authorization must not. req, _ := http.NewRequest("POST", ingest.URL+"/h/"+secret, strings.NewReader(`{"action":"completed"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "workflow_run") req.Header.Set("Authorization", "Bearer sender-secret") dresp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } dresp.Body.Close() if dresp.StatusCode != http.StatusOK { t.Fatalf("delivery: status %d", dresp.StatusCode) } event := lastEventInThread(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64) if event["type"] != "webhooks.github" { t.Errorf("type = %v, want webhooks.github", event["type"]) } if event["name"] != "CI runs" || event["uid"] != wh.UserID { t.Errorf("attribution: name=%v uid=%v", event["name"], event["uid"]) } reqPart, _ := event["req"].(map[string]any) if reqPart == nil { t.Fatalf("no req in event: %v", event) } if reqPart["method"] != "POST" || reqPart["body"] != `{"action":"completed"}` { t.Errorf("req = %v", reqPart) } headers, _ := reqPart["headers"].(map[string]any) if headers["X-Github-Event"] != "workflow_run" { t.Errorf("allowlisted header missing: %v", headers) } for k := range headers { if strings.EqualFold(k, "Authorization") { t.Error("Authorization header captured into the event") } } // Ingest hygiene: unknown secret 404s, oversize 413s. if r, _ := http.Post(ingest.URL+"/h/nope", "text/plain", strings.NewReader("x")); r.StatusCode != http.StatusNotFound { t.Errorf("unknown secret: status %d, want 404", r.StatusCode) } if r, _ := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader(strings.Repeat("a", 41*1024))); r.StatusCode != http.StatusRequestEntityTooLarge { t.Errorf("oversize body: status %d, want 413", r.StatusCode) } // Delete retires the whole identity; the next poll drops the route and the // URL dies. dreq, _ := http.NewRequest("DELETE", ts.URL+"/api/hooks/"+id, nil) dreq.Header.Set("Authorization", "Bearer "+adminTok) delResp, err := http.DefaultClient.Do(dreq) if err != nil { t.Fatal(err) } delResp.Body.Close() if delResp.StatusCode != http.StatusOK { t.Fatalf("delete: status %d", delResp.StatusCode) } if s.db.userExists(wh.UserID) { t.Error("webhook user survived deletion") } if err := sc.PollOnce(); err != nil { t.Fatalf("poll after delete: %v", err) } if sc.Routes() != 0 { t.Errorf("routes after delete = %d, want 0", sc.Routes()) } if r, _ := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader("x")); r.StatusCode != http.StatusNotFound { t.Errorf("deleted webhook still ingesting: status %d", r.StatusCode) } } func TestWebhookSpaceThread(t *testing.T) { ts, s := newTestServer(t) sess := provisionHooksService(t, ts.URL) adminTok, _ := mkAdminMember(t, s, "Paula") // The space thread exists with an open_key only members can unwrap; the // webhook gets the SAME thread key wrapped to its own pubkey instead — the // explicit agent opt-in. spaceID, _, created, err := s.db.createSpaceThread("opaque-open-key") if err != nil || !created { t.Fatalf("createSpaceThread: created=%v err=%v", created, err) } threadKey, _ := e2ee.GenerateKey() threadKeyB64 := e2ee.B64uEncode(threadKey) id, secret := createWebhookAsAdmin(t, ts.URL, adminTok, spaceID, threadKeyB64, sess.UserPub, "Deploy feed", "github") sc := hooks.New(ts.URL, sess, t.Logf) if err := sc.PollOnce(); err != nil { t.Fatalf("poll: %v", err) } ingest := httptest.NewServer(sc.Handler()) defer ingest.Close() if r, _ := http.Post(ingest.URL+"/h/"+secret, "application/json", strings.NewReader(`{"run":1}`)); r.StatusCode != http.StatusOK { t.Fatalf("space delivery: status %d", r.StatusCode) } event := lastEventInThread(t, ts.URL, adminTok, spaceID, threadKeyB64) if event["type"] != "webhooks.github" { t.Errorf("type = %v", event["type"]) } _ = id } func TestWebhookGates(t *testing.T) { ts, s := newTestServer(t) // A plain member can neither create nor list webhooks. memberTok := mintDeviceToken(t, s.db, "Mallory") body, _ := json.Marshal(map[string]string{"name": "x", "public_key": "p", "thread_id": "t", "wrapped": "w"}) req, _ := http.NewRequest("POST", ts.URL+"/api/hooks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+memberTok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("member create: status %d, want 401", resp.StatusCode) } // The defs poll needs an agent token — a person's token is refused. r, err := http.Get(ts.URL + "/api/hooks/defs?token=" + memberTok) if err != nil { t.Fatal(err) } r.Body.Close() if r.StatusCode != http.StatusForbidden { t.Errorf("member defs poll: status %d, want 403", r.StatusCode) } // Creating into a DM is refused even for an admin (webhooks feed groups and // the space thread only). adminTok, adminUID := mkAdminMember(t, s, "Paula") dmBody := `{"token":"` + adminTok + `","kind":"dm","members":[{"user_id":"` + adminUID + `","wrapped":"wa"},{"user_id":"` + mustUID(t, s, memberTok) + `","wrapped":"wb"}]}` tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(dmBody)) if err != nil { t.Fatal(err) } var tout struct { ThreadID string `json:"thread_id"` } json.NewDecoder(tresp.Body).Decode(&tout) tresp.Body.Close() body, _ = json.Marshal(map[string]string{"name": "x", "public_key": "p", "thread_id": tout.ThreadID, "wrapped": "w"}) req, _ = http.NewRequest("POST", ts.URL+"/api/hooks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+adminTok) resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("create into DM: status %d, want 400", resp.StatusCode) } } // TestWebhookIngestBaseOverride: LCHAT_HOOKS_BASE (dev: the sidecar on a bare // local port) is echoed as ingest_base on the service endpoint; unset — the // production default — omits it so clients derive https://in.. func TestWebhookIngestBaseOverride(t *testing.T) { ts, s := newTestServer(t) sess := provisionHooksService(t, ts.URL) get := func() map[string]any { t.Helper() resp, err := http.Get(ts.URL + "/api/hooks/service?token=" + sess.Token) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out map[string]any if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) } return out } if out := get(); out["ingest_base"] != nil { t.Errorf("ingest_base = %v with no override, want absent", out["ingest_base"]) } s.hooksBase = "http://127.0.0.1:9999" if out := get(); out["ingest_base"] != "http://127.0.0.1:9999" { t.Errorf("ingest_base = %v, want the override", out["ingest_base"]) } } // TestWebhookRateLimit drains one webhook's bucket and expects 429 — the // sidecar-side dial; the server's message brake backstops it independently. func TestWebhookRateLimit(t *testing.T) { ts, s := newTestServer(t) sess := provisionHooksService(t, ts.URL) adminTok, adminUID := mkAdminMember(t, s, "Paula") threadKey, _ := e2ee.GenerateKey() threadKeyB64 := e2ee.B64uEncode(threadKey) body := `{"token":"` + adminTok + `","kind":"group","members":[{"user_id":"` + adminUID + `","wrapped":"wa"}]}` tresp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body)) if err != nil { t.Fatal(err) } var tout struct { ThreadID string `json:"thread_id"` } json.NewDecoder(tresp.Body).Decode(&tout) tresp.Body.Close() _, secret := createWebhookAsAdmin(t, ts.URL, adminTok, tout.ThreadID, threadKeyB64, sess.UserPub, "Chatty", "github") sc := hooks.New(ts.URL, sess, t.Logf) if err := sc.PollOnce(); err != nil { t.Fatal(err) } ingest := httptest.NewServer(sc.Handler()) defer ingest.Close() limited := false for i := 0; i < 100; i++ { r, err := http.Post(ingest.URL+"/h/"+secret, "text/plain", strings.NewReader(fmt.Sprintf("n=%d", i))) if err != nil { t.Fatal(err) } r.Body.Close() if r.StatusCode == http.StatusTooManyRequests { limited = true break } } if !limited { t.Error("100 rapid deliveries never rate-limited") } }