275 lines
8.2 KiB
Go
275 lines
8.2 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|