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{} }