Artifact 371d259da23ffdae1fdbd10ac6a2532a6165062f03cf1edfd1b107106cda9e46:
- File
internal/webhook/webhook_test.go
— part of check-in
[550b7af81c]
at
2026-08-16 19:38:20
on branch trunk
— webhooks: the standard outbound surface (0001 Principle 6)
Activity events reach third parties over signed HTTP, so an integrator never has to speak NATS. Subscriptions live in forge OLTP; the signing secret is printed once at creation and never readable again.
Delivery is durable rather than a fire-and-forget goroutine: each (event, subscription) is one JetStream message, acked on 2xx, NAKed with growing backoff, MaxDeliver as the dead-letter boundary. An endpoint down during a deploy still gets its events โ proven live by writing with the receiver stopped and seeing the delivery land when it returned.
0003 ยง3 names dagnats for this class of work; it runs on the JetStream quarry already embeds, so this uses that directly and leaves dagnats as the upgrade path when v3 brings the engine in for CI.
Mentions are not forwarded: they address a person, not a repository. (user: dmestas, size: 6264) [annotate] [blame] [check-ins using]
package webhook import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "sync" "sync/atomic" "testing" "time" "craftdesign.group/quarry/internal/events" ) // fakeSubs is an in-memory Subscriptions, so the dispatcher's tests do // not need the OLTP database. type fakeSubs struct { mu sync.Mutex subs map[int64]Sub } func (f *fakeSubs) ActiveWebhooks(string) ([]Sub, error) { f.mu.Lock() defer f.mu.Unlock() var out []Sub for _, s := range f.subs { if s.Active { out = append(out, s) } } return out, nil } func (f *fakeSubs) SubByID(id int64) (Sub, bool) { f.mu.Lock() defer f.mu.Unlock() s, ok := f.subs[id] return s, ok } func (f *fakeSubs) set(s Sub) { f.mu.Lock() defer f.mu.Unlock() f.subs[s.ID] = s } func testDispatcher(t *testing.T, subs Subscriptions) *Dispatcher { t.Helper() ev, err := events.Start(filepath.Join(t.TempDir(), "nats")) if err != nil { t.Fatal(err) } t.Cleanup(ev.Close) d, err := New(ev.JS(), subs) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) if err := d.Start(ctx); err != nil { t.Fatal(err) } return d } func TestSignAndVerify(t *testing.T) { body := []byte(`{"kind":"ticket.new"}`) sig := Sign("s3cret", body) if !Verify("s3cret", body, sig) { t.Fatal("signature does not verify with the right secret") } if Verify("wrong", body, sig) { t.Fatal("signature verified under the wrong secret") } // A signature must cover the exact bytes, or a receiver that // verifies-then-parses can be fed different content than was signed. if Verify("s3cret", []byte(`{"kind":"ticket.set"}`), sig) { t.Fatal("signature verified over different bytes") } } // The happy path: a raised event reaches the endpoint, signed, with the // headers a receiver routes on. func TestDeliversSignedEvent(t *testing.T) { got := make(chan *http.Request, 1) bodies := make(chan []byte, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) bodies <- b got <- r w.WriteHeader(http.StatusOK) })) defer srv.Close() subs := &fakeSubs{subs: map[int64]Sub{ 1: {ID: 1, URL: srv.URL, Secret: "k", Active: true}, }} d := testDispatcher(t, subs) want := Event{ID: "evt-1", Repo: "alpha", Kind: "ticket.new", Actor: "dan", Summary: "opened ticket", Link: "/r/alpha/ticket/abc", At: "2026-08-16T00:00:00Z"} if err := d.Raise(want); err != nil { t.Fatal(err) } select { case req := <-got: body := <-bodies if req.Header.Get("X-Quarry-Event") != "ticket.new" { t.Errorf("event header = %q", req.Header.Get("X-Quarry-Event")) } if req.Header.Get("X-Quarry-Repo") != "alpha" { t.Errorf("repo header = %q", req.Header.Get("X-Quarry-Repo")) } if req.Header.Get("X-Quarry-Delivery") != "evt-1" { t.Errorf("delivery header = %q", req.Header.Get("X-Quarry-Delivery")) } if !Verify("k", body, req.Header.Get("X-Quarry-Signature")) { t.Error("signature does not verify over the delivered body") } var e Event if err := json.Unmarshal(body, &e); err != nil { t.Fatal(err) } if e != want { t.Errorf("payload = %+v, want %+v", e, want) } case <-time.After(15 * time.Second): t.Fatal("no delivery") } } // A failing endpoint must be retried, not dropped โ that is the whole // reason delivery is durable rather than a fire-and-forget goroutine. func TestRetriesUntilAccepted(t *testing.T) { var attempts atomic.Int32 done := make(chan struct{}) var once sync.Once srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if attempts.Add(1) < 3 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) once.Do(func() { close(done) }) })) defer srv.Close() subs := &fakeSubs{subs: map[int64]Sub{ 1: {ID: 1, URL: srv.URL, Secret: "k", Active: true}, }} d := testDispatcher(t, subs) if err := d.Raise(Event{ID: "evt-retry", Repo: "alpha", Kind: "wiki.save"}); err != nil { t.Fatal(err) } select { case <-done: if n := attempts.Load(); n < 3 { t.Fatalf("accepted after %d attempts, expected retries", n) } case <-time.After(40 * time.Second): t.Fatalf("never accepted after %d attempts", attempts.Load()) } } // Unsubscribing must actually stop delivery, including for events // already queued โ otherwise "delete" is advisory. func TestPausedSubscriptionStopsDelivery(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) w.WriteHeader(http.StatusOK) })) defer srv.Close() subs := &fakeSubs{subs: map[int64]Sub{ 1: {ID: 1, URL: srv.URL, Secret: "k", Active: true}, }} d := testDispatcher(t, subs) // Queue first, then pause before the worker can get to it. if err := d.Raise(Event{ID: "evt-paused", Repo: "alpha", Kind: "wiki.save"}); err != nil { t.Fatal(err) } subs.set(Sub{ID: 1, URL: srv.URL, Secret: "k", Active: false}) time.Sleep(6 * time.Second) if n := hits.Load(); n != 0 { t.Fatalf("paused subscription received %d deliveries", n) } } // Two subscriptions on one repo each get their own delivery, and one // failing endpoint must not starve the other. func TestFansOutPerSubscription(t *testing.T) { var good, bad atomic.Int32 okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { good.Add(1) w.WriteHeader(http.StatusOK) })) defer okSrv.Close() badSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { bad.Add(1) w.WriteHeader(http.StatusBadGateway) })) defer badSrv.Close() subs := &fakeSubs{subs: map[int64]Sub{ 1: {ID: 1, URL: okSrv.URL, Secret: "k", Active: true}, 2: {ID: 2, URL: badSrv.URL, Secret: "k", Active: true}, }} d := testDispatcher(t, subs) if err := d.Raise(Event{ID: "evt-fan", Repo: "alpha", Kind: "forum.post"}); err != nil { t.Fatal(err) } deadline := time.After(20 * time.Second) for good.Load() == 0 { select { case <-deadline: t.Fatal("healthy endpoint never received its delivery") case <-time.After(200 * time.Millisecond): } } if bad.Load() == 0 { t.Error("failing endpoint was never attempted") } }