Collector milestone 3: HTTP webhook source + /health
internal/source/webhook routes inbound POSTs to inbox events via a
configured table. Each route specifies recipient, type, priority, and
a Go text/template payload renderer that consumes the request body
decoded as JSON.
v1 binds loopback only — New() rejects non-loopback addresses at
construction. Caddy + bearer-token reverse-proxy is the v2 upgrade
path per spec §4.
Behavior:
- POST + matched route + valid JSON body → render template, emit, 202
- Missing route → 404
- Wrong method → 405
- Bad JSON → 400
- Template render failure → 500
- Emit failure → 500 (caller responsible for retry; HTTP source has no
durable staging)
- Empty body → empty data map for template (lets fixed-string templates
work without sending {})
- 1 MiB request body cap
GET /health returns JSON Stats{received, emitted, errors, uptime_sec}
on the same listener for journalctl correlation per spec §3.5.
10 tests passing — non-loopback rejection, bad type/template
rejection, route+template happy path, priority defaulting, empty body,
404/400/405/500, health endpoint counters.
31 tests across the three internal packages, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9d81471c4
commit
ba6db7c82f
2 changed files with 571 additions and 0 deletions
323
internal/source/webhook/webhook_test.go
Normal file
323
internal/source/webhook/webhook_test.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.botbought.ai/foreman/agent-watcher/internal/inbox"
|
||||
)
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// freePort returns an unused loopback "127.0.0.1:N" address.
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
type recordingEmit struct {
|
||||
mu sync.Mutex
|
||||
events []record
|
||||
err error
|
||||
}
|
||||
|
||||
type record struct {
|
||||
recipient string
|
||||
event inbox.Event
|
||||
}
|
||||
|
||||
func (r *recordingEmit) emit(recipient string, ev *inbox.Event) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.events = append(r.events, record{recipient, *ev})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingEmit) snap() []record {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]record, len(r.events))
|
||||
copy(out, r.events)
|
||||
return out
|
||||
}
|
||||
|
||||
func runSource(t *testing.T, cfg Config, emit func(string, *inbox.Event) error) (string, context.CancelFunc) {
|
||||
t.Helper()
|
||||
src, err := New(cfg, quietLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go src.Run(ctx, emit)
|
||||
// Wait for listener.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, err := net.DialTimeout("tcp", cfg.Listen, 50*time.Millisecond)
|
||||
if err == nil {
|
||||
c.Close()
|
||||
return cfg.Listen, cancel
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
t.Fatal("listener never came up")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestNew_RejectsNonLoopback(t *testing.T) {
|
||||
_, err := New(Config{Listen: "0.0.0.0:18790"}, quietLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for non-loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsBadType(t *testing.T) {
|
||||
_, err := New(Config{
|
||||
Listen: "127.0.0.1:0",
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "BOGUS", PayloadTemplate: "x"},
|
||||
},
|
||||
}, quietLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for bad type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsBadTemplate(t *testing.T) {
|
||||
_, err := New(Config{
|
||||
Listen: "127.0.0.1:0",
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "INFO", PayloadTemplate: "{{ .unclosed"},
|
||||
},
|
||||
}, quietLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for bad template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPOST_RoutesAndEmits(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/forgejo/push": {
|
||||
Recipient: "bob",
|
||||
Type: "INFO",
|
||||
PayloadTemplate: "forgejo push to {{ .repo }} by {{ .actor }}",
|
||||
},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
body := `{"repo":"agent-ping","actor":"angus"}`
|
||||
resp, err := http.Post("http://"+addr+"/forgejo/push", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Errorf("status = %d, want 202", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := rec.snap()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(got))
|
||||
}
|
||||
if got[0].recipient != "bob" {
|
||||
t.Errorf("recipient = %q", got[0].recipient)
|
||||
}
|
||||
if got[0].event.Type != "INFO" {
|
||||
t.Errorf("type = %q", got[0].event.Type)
|
||||
}
|
||||
if got[0].event.Priority != "normal" {
|
||||
t.Errorf("priority default = %q", got[0].event.Priority)
|
||||
}
|
||||
if got[0].event.Payload != "forgejo push to agent-ping by angus" {
|
||||
t.Errorf("payload = %q", got[0].event.Payload)
|
||||
}
|
||||
if got[0].event.Source != "webhook:/forgejo/push" {
|
||||
t.Errorf("source = %q", got[0].event.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPOST_PriorityAndEmptyBody(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/alert": {
|
||||
Recipient: "bob",
|
||||
Type: "NEEDS-RESPONSE",
|
||||
Priority: "urgent",
|
||||
PayloadTemplate: "alert fired",
|
||||
},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post("http://"+addr+"/alert", "application/json", strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Errorf("status = %d", resp.StatusCode)
|
||||
}
|
||||
got := rec.snap()[0]
|
||||
if got.event.Priority != "urgent" {
|
||||
t.Errorf("priority = %q", got.event.Priority)
|
||||
}
|
||||
if got.event.Payload != "alert fired" {
|
||||
t.Errorf("payload = %q", got.event.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPOST_404OnUnknownRoute(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{Listen: addr, Routes: map[string]Route{}}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post("http://"+addr+"/nope", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if len(rec.snap()) != 0 {
|
||||
t.Error("emitted on unknown route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPOST_400OnBadJSON(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "INFO", PayloadTemplate: "x"},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post("http://"+addr+"/x", "application/json", strings.NewReader("not json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPOST_500OnEmitFailure(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "INFO", PayloadTemplate: "x"},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{err: errOnce()}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post("http://"+addr+"/x", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGET_MethodNotAllowed(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "INFO", PayloadTemplate: "x"},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGET_HealthEndpoint(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
cfg := Config{
|
||||
Listen: addr,
|
||||
Routes: map[string]Route{
|
||||
"/x": {Recipient: "r", Type: "INFO", PayloadTemplate: "x"},
|
||||
},
|
||||
}
|
||||
rec := &recordingEmit{}
|
||||
_, cancel := runSource(t, cfg, rec.emit)
|
||||
defer cancel()
|
||||
|
||||
// Two POSTs: 1 success, 1 fail.
|
||||
http.Post("http://"+addr+"/x", "application/json", strings.NewReader("{}"))
|
||||
http.Post("http://"+addr+"/wrongpath", "application/json", strings.NewReader("{}"))
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var stats Stats
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Received < 2 {
|
||||
t.Errorf("received = %d", stats.Received)
|
||||
}
|
||||
if stats.Emitted != 1 {
|
||||
t.Errorf("emitted = %d, want 1", stats.Emitted)
|
||||
}
|
||||
}
|
||||
|
||||
type emitErr struct{}
|
||||
|
||||
func (e *emitErr) Error() string { return "emit boom" }
|
||||
func errOnce() error { return &emitErr{} }
|
||||
Loading…
Add table
Add a link
Reference in a new issue