// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package webhooks import ( "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "slices" "strings" "sync/atomic" "testing" "time" ) // A hook with an event filter receives only those events, and one with no // filter receives all of them. A disabled hook receives none. func TestWebhookAccepts(t *testing.T) { hook := Webhook{ URL: "https://example.com/hook", Secret: "s3cret", Events: []string{"post.created"}, Enabled: true, } if !hook.Accepts("post.created") || hook.Accepts("post.deleted") { t.Fatal("event filter broken") } all := Webhook{URL: "https://x.example", Enabled: true} if !all.Accepts("anything") { t.Fatalf("hook = %+v", all) } disabled := Webhook{URL: "https://x.example"} if disabled.Accepts("post.created") { t.Fatal("disabled hook accepts events") } } func TestFireDeliversSignedPayload(t *testing.T) { var gotBody []byte var gotEvent, gotSig, gotDelivery string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotBody, _ = io.ReadAll(r.Body) gotEvent = r.Header.Get("X-Volumen-Event") gotSig = r.Header.Get("X-Volumen-Signature") gotDelivery = r.Header.Get("X-Volumen-Delivery") w.WriteHeader(http.StatusOK) })) defer srv.Close() m := NewManager([]Webhook{{URL: srv.URL, Secret: "k", Enabled: true}}, "0.0.0-test") if n := m.Fire("post.created", map[string]any{"post": map[string]any{"slug": "x"}}, true); n != 1 { t.Fatalf("targets = %d", n) } if gotEvent != "post.created" || gotDelivery == "" { t.Fatalf("headers: event=%q delivery=%q", gotEvent, gotDelivery) } if !strings.HasPrefix(gotSig, "sha256=") { t.Fatalf("signature = %q", gotSig) } var payload map[string]any if err := json.Unmarshal(gotBody, &payload); err != nil { t.Fatalf("body: %v", err) } if payload["event"] != "post.created" || payload["version"] != "0.0.0-test" { t.Fatalf("payload = %v", payload) } deliveries := m.Deliveries("") if len(deliveries) != 1 || deliveries[0].Status != "ok" || deliveries[0].Attempts != 1 { t.Fatalf("deliveries = %+v", deliveries) } } func TestFireSkipsNonMatchingHooks(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) })) defer srv.Close() m := NewManager([]Webhook{{URL: srv.URL, Events: []string{"post.deleted"}, Enabled: true}}, "t") if n := m.Fire("post.created", nil, true); n != 0 { t.Fatalf("targets = %d", n) } if hits.Load() != 0 { t.Fatal("non-matching hook was contacted") } } // withoutBackoff removes the retry sleeps for the duration of one test, // so the suite does not spend the configured backoff in real time. func withoutBackoff(t *testing.T) { t.Helper() original := retryBackoff retryBackoff = make([]time.Duration, len(original)) t.Cleanup(func() { retryBackoff = original }) } func TestFireRetriesAndRecordsFailure(t *testing.T) { withoutBackoff(t) var attempts int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&attempts, 1) w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() m := NewManager([]Webhook{{URL: srv.URL, Enabled: true}}, "t") m.Fire("post.updated", nil, true) if atomic.LoadInt32(&attempts) != maxAttempts { t.Fatalf("attempts = %d, want %d", attempts, maxAttempts) } d := m.Deliveries("") if len(d) != 1 || d[0].Status != "failed" || d[0].Attempts != maxAttempts { t.Fatalf("deliveries = %+v", d) } if !strings.Contains(d[0].Error, "HTTP 500") { t.Fatalf("error = %q", d[0].Error) } } func TestFireRecordsConnectionError(t *testing.T) { withoutBackoff(t) m := NewManager([]Webhook{{URL: "http://127.0.0.1:1/none", Enabled: true}}, "t") m.Fire("post.deleted", nil, true) d := m.Deliveries("") if len(d) != 1 || d[0].Status != "failed" || d[0].Error == "" { t.Fatalf("deliveries = %+v", d) } } func TestTestHookPingIgnoresFilters(t *testing.T) { var gotEvent string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotEvent = r.Header.Get("X-Volumen-Event") })) defer srv.Close() hook := Webhook{URL: srv.URL, Events: []string{"post.created"}, Enabled: true} m := NewManager([]Webhook{hook}, "t") m.TestHook(hook) if gotEvent != "ping" { t.Fatalf("event = %q", gotEvent) } } func TestDeliveriesFilterByHook(t *testing.T) { ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) defer ok.Close() m := NewManager([]Webhook{ {URL: ok.URL + "/a", Enabled: true}, {URL: ok.URL + "/b", Enabled: true}, }, "t") m.Fire("post.created", nil, true) if got := len(m.Deliveries(ok.URL + "/a")); got != 1 { t.Fatalf("filtered deliveries = %d", got) } if got := len(m.Deliveries("")); got != 2 { t.Fatalf("all deliveries = %d", got) } } func TestHistoryIsCapped(t *testing.T) { ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) defer ok.Close() m := NewManager([]Webhook{{URL: ok.URL, Enabled: true}}, "t") for range historyLimit + 10 { m.Fire("ping", nil, true) } if got := len(m.Deliveries("")); got != historyLimit { t.Fatalf("history = %d, want %d", got, historyLimit) } } // Production fires asynchronously; the delivery must still be recorded, // and Wait must not return before it is. func TestFireAsyncIsRecordedAndWaitedFor(t *testing.T) { delivered := make(chan struct{}, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) select { case delivered <- struct{}{}: default: } })) defer srv.Close() m := NewManager([]Webhook{{URL: srv.URL, Enabled: true}}, "t") if n := m.Fire("post.created", nil, false); n != 1 { t.Fatalf("targets = %d", n) } m.Wait() select { case <-delivered: default: t.Fatal("Wait returned before the delivery reached the hook") } if d := m.Deliveries(""); len(d) != 1 || d[0].Status != "ok" { t.Fatalf("deliveries = %+v", d) } } // The hooks are handed out as a copy, so a caller cannot change what the // manager delivers. func TestHooksIsACopy(t *testing.T) { m := NewManager([]Webhook{{URL: "https://example.com/h", Enabled: true}}, "t") hooks := m.Hooks() hooks[0].URL = "https://evil.example" if got := m.Hooks()[0].URL; got != "https://example.com/h" { t.Fatalf("hook mutated through the returned slice: %q", got) } } // SetHooks swaps the delivery targets without building a new manager, // which is how an admin settings change applies without a restart. func TestSetHooksAppliesImmediately(t *testing.T) { delivered := make(chan string, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) delivered <- r.Header.Get("X-Volumen-Event") + ":" + string(body) })) defer srv.Close() m := NewManager(nil, "t") if n := m.Fire("post.created", nil, false); n != 0 { t.Fatalf("targets with no hooks = %d", n) } m.SetHooks([]Webhook{{URL: srv.URL, Enabled: true}}) if n := m.Fire("post.created", nil, false); n != 1 { t.Fatalf("targets after SetHooks = %d", n) } m.Wait() select { case <-delivered: default: t.Fatal("the new hook did not receive the event") } } // The admin-managed store round-trips through webhooks.toml: the fields // survive a save and a missing file means no hooks, not an error. func TestFileRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "webhooks.toml") hooks, err := LoadFile(path) if err != nil || hooks != nil { t.Fatalf("missing file: hooks = %+v, err = %v", hooks, err) } want := []Webhook{ {URL: "https://example.com/one", Secret: "k", Events: []string{"post.created"}, Enabled: true}, {URL: "https://example.com/two", Enabled: false}, } if err := SaveFile(path, want); err != nil { t.Fatalf("SaveFile: %v", err) } got, err := LoadFile(path) if err != nil { t.Fatalf("LoadFile: %v", err) } if len(got) != len(want) { t.Fatalf("hooks = %+v, want %+v", got, want) } for i := range want { if got[i].URL != want[i].URL || got[i].Secret != want[i].Secret || !slices.Equal(got[i].Events, want[i].Events) || got[i].Enabled != want[i].Enabled { t.Fatalf("hook %d = %+v, want %+v", i, got[i], want[i]) } } }