package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) func newTestServer(cfg *Configuration) (*httptest.Server, *Broker, context.CancelFunc) { backend := NewMemoryBackend(100) broker := NewBroker(backend) ctx, cancel := context.WithCancel(context.Background()) broker.Start(ctx) var cfgPtr atomic.Pointer[Configuration] if cfg != nil { cfgPtr.Store(cfg) } handler := NewServer(broker, &cfgPtr) return httptest.NewServer(handler), broker, cancel } func TestServer_healthEndpoint(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() resp, err := http.Get(ts.URL + "/_health") if err != nil { t.Fatalf("GET /_health failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Errorf("expected 204, got %d", resp.StatusCode) } buf := make([]byte, 1) n, _ := resp.Body.Read(buf) if n != 0 { t.Errorf("expected empty body, got %d bytes", n) } } func TestServer_postPublishesEvent(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() resp, err := http.Post(ts.URL+"/test/topic", "application/json", strings.NewReader(`{"hello":"world"}`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Errorf("expected 202, got %d", resp.StatusCode) } } func TestServer_postTrailingSlashNormalized(t *testing.T) { ts, broker, cancel := newTestServer(nil) defer cancel() defer ts.Close() ch, unsub := broker.Subscribe("test/topic", "") defer unsub() http.Post(ts.URL+"/test/topic/", "application/json", strings.NewReader(`{}`)) select { case event := <-ch: if event.Path != "test/topic" { t.Errorf("expected normalized path test/topic, got %s", event.Path) } case <-time.After(time.Second): t.Fatal("timed out: POST with trailing slash should deliver to normalized path") } } func TestServer_postWithValidHMAC(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "secure/path": { Verify: "hmac-sha256", Secret: "test-secret", SignatureHeader: "X-Hub-Signature-256", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() body := `{"action":"push"}` mac := hmac.New(sha256.New, []byte("test-secret")) mac.Write([]byte(body)) sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) req, _ := http.NewRequest("POST", ts.URL+"/secure/path", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Hub-Signature-256", sig) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Errorf("expected 202, got %d", resp.StatusCode) } } func TestServer_postWithInvalidSignature(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "secure/path": { Verify: "hmac-sha256", Secret: "test-secret", SignatureHeader: "X-Hub-Signature-256", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() req, _ := http.NewRequest("POST", ts.URL+"/secure/path", strings.NewReader(`{"bad":"data"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Hub-Signature-256", "sha256=deadbeef") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403, got %d", resp.StatusCode) } } func TestServer_postToUnconfiguredPath(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "secure/path": { Verify: "hmac-sha256", Secret: "test-secret", SignatureHeader: "X-Hub-Signature-256", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() resp, err := http.Post(ts.URL+"/open/path", "application/json", strings.NewReader(`{"ok":true}`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Errorf("expected 202, got %d", resp.StatusCode) } } func TestServer_getWithoutSSEAccept(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() resp, err := http.Get(ts.URL + "/test/topic") if err != nil { t.Fatalf("GET failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("expected 404, got %d", resp.StatusCode) } } func TestServer_corsHeaders(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() resp, err := http.Post(ts.URL+"/test", "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.Header.Get("Access-Control-Allow-Origin") != "*" { t.Error("missing CORS Allow-Origin header") } } func TestServer_optionsPreflight(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() req, _ := http.NewRequest("OPTIONS", ts.URL+"/test", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("OPTIONS failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Errorf("expected 204, got %d", resp.StatusCode) } if resp.Header.Get("Access-Control-Allow-Methods") == "" { t.Error("missing CORS Allow-Methods header") } } func TestServer_methodNotAllowed(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() req, _ := http.NewRequest("DELETE", ts.URL+"/test", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("DELETE failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("expected 405, got %d", resp.StatusCode) } } func TestServer_postWithBadVerifierConfig(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "bad/path": { Verify: "unknown-method", Secret: "secret", SignatureHeader: "X-Signature", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() req, _ := http.NewRequest("POST", ts.URL+"/bad/path", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusInternalServerError { t.Errorf("expected 500, got %d", resp.StatusCode) } } func TestServer_postInvalidJSON(t *testing.T) { ts, _, cancel := newTestServer(nil) defer cancel() defer ts.Close() resp, err := http.Post(ts.URL+"/test", "application/json", strings.NewReader(`{not json`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("expected 400, got %d", resp.StatusCode) } } type bareResponseWriter struct { code int headers http.Header body strings.Builder } func (w *bareResponseWriter) Header() http.Header { return w.headers } func (w *bareResponseWriter) Write(b []byte) (int, error) { return w.body.Write(b) } func (w *bareResponseWriter) WriteHeader(code int) { w.code = code } func TestServer_sseWithoutFlusher(t *testing.T) { backend := NewMemoryBackend(100) broker := NewBroker(backend) ctx, cancel := context.WithCancel(context.Background()) defer cancel() broker.Start(ctx) var cfgPtr atomic.Pointer[Configuration] handler := NewServer(broker, &cfgPtr) w := &bareResponseWriter{headers: make(http.Header)} req := httptest.NewRequest("GET", "/test/topic", nil) req.Header.Set("Accept", "text/event-stream") handler.ServeHTTP(w, req) if w.code != http.StatusInternalServerError { t.Errorf("expected 500, got %d", w.code) } } func TestServer_postInheritsVerificationFromParent(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "github.com": { Verify: "hmac-sha256", Secret: "parent-secret", SignatureHeader: "X-Hub-Signature-256", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() body := `{"action":"push"}` mac := hmac.New(sha256.New, []byte("parent-secret")) mac.Write([]byte(body)) sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) req, _ := http.NewRequest("POST", ts.URL+"/github.com/org/repo", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Hub-Signature-256", sig) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Errorf("expected 202 with valid signature, got %d", resp.StatusCode) } req, _ = http.NewRequest("POST", ts.URL+"/github.com/org/repo", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Hub-Signature-256", "sha256=deadbeef") resp, err = http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 with invalid signature, got %d", resp.StatusCode) } } type failingBackend struct{ MemoryBackend } func (f *failingBackend) Publish(*Event) error { return fmt.Errorf("backend unavailable") } func TestServer_postPublishError(t *testing.T) { backend := &failingBackend{MemoryBackend: *NewMemoryBackend(100)} broker := NewBroker(backend) ctx, cancel := context.WithCancel(context.Background()) defer cancel() broker.Start(ctx) var cfgPtr atomic.Pointer[Configuration] handler := NewServer(broker, &cfgPtr) ts := httptest.NewServer(handler) defer ts.Close() resp, err := http.Post(ts.URL+"/test", "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusInternalServerError { t.Errorf("expected 500, got %d", resp.StatusCode) } } func TestServer_postWithMissingSignatureOnSecuredPath(t *testing.T) { cfg := &Configuration{ Paths: map[string]PathConfiguration{ "secure/path": { Verify: "hmac-sha256", Secret: "test-secret", SignatureHeader: "X-Hub-Signature-256", }, }, } ts, _, cancel := newTestServer(cfg) defer cancel() defer ts.Close() resp, err := http.Post(ts.URL+"/secure/path", "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatalf("POST failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403, got %d", resp.StatusCode) } }