package main import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "net/http" "net/http/httptest" "strings" "testing" ) // newTestWebhookHandler builds a WebhookHandler backed by an isolated // in-memory SettingsManager. It is the preferred factory for all webhook tests. func newTestWebhookHandler(t *testing.T) (*WebhookHandler, *SettingsManager) { t.Helper() settings, _ := newTestSettings() h := NewWebhookHandler(settings, slog.Default()) return h, settings } // computeSig returns the HMAC-SHA256 hex digest for the given body and secret. func computeSig(body []byte, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) return hex.EncodeToString(mac.Sum(nil)) } // bookingEventBody builds a minimal, valid Cal.com webhook JSON payload. func bookingEventBody(trigger, uid, attendeeName, eventTitle, startTime string) []byte { return fmt.Appendf(nil, `{"triggerEvent":%q,"createdAt":"2026-05-27T10:00:00Z","payload":{"uid":%q,"startTime":%q,"eventType":{"title":%q},"attendees":[{"name":%q}]}}`, trigger, uid, startTime, eventTitle, attendeeName, ) } // sendSigned posts body to h with a correct "sha256=" signature header // and returns the response recorder. func sendSigned(t *testing.T, h *WebhookHandler, body []byte, secret string) *httptest.ResponseRecorder { t.Helper() sig := "sha256=" + computeSig(body, secret) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) return rec } // ------------------------------------------------------------------------- // WO-047 — signature validation // ------------------------------------------------------------------------- // TestWebhook_ValidSignatureSha256Hex verifies that a request with the // canonical "sha256=" signature format is accepted with 200 OK. func TestWebhook_ValidSignatureSha256Hex(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } var called int SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ }) t.Cleanup(func() { SetNotifyFn(nil) }) body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z") sig := "sha256=" + computeSig(body, "supersecret") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if called != 1 { t.Errorf("expected notifier called once, got %d", called) } } // TestWebhook_ValidSignatureBareHex verifies that the bare "" format // (without the "sha256=" prefix) is also accepted. func TestWebhook_ValidSignatureBareHex(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } var called int SetNotifyFn(func(_ context.Context, _, _, _, _ string) { called++ }) t.Cleanup(func() { SetNotifyFn(nil) }) body := bookingEventBody("BOOKING_CREATED", "u2", "Alice", "Demo", "2026-05-27T11:00:00Z") // Bare hex — no "sha256=" prefix. sig := computeSig(body, "supersecret") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if called != 1 { t.Errorf("expected notifier called once, got %d", called) } } // TestWebhook_TamperedBody401 verifies that modifying the body after // computing the signature triggers a 401 Unauthorized. func TestWebhook_TamperedBody401(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } original := bookingEventBody("BOOKING_CREATED", "u3", "Eve", "Workshop", "2026-05-27T12:00:00Z") sig := "sha256=" + computeSig(original, "supersecret") // Tamper: change "Eve" to "Eve2" in the body after signing. tampered := bytes.Replace(original, []byte("Eve"), []byte("Eve2"), 1) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(tampered)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rec.Code) } } // TestWebhook_WrongSecret401 verifies that signing with a different secret // than the one configured in settings returns 401 Unauthorized. func TestWebhook_WrongSecret401(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "right-secret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } body := bookingEventBody("BOOKING_CREATED", "u4", "Mallory", "Hack", "2026-05-27T13:00:00Z") // Signed with the wrong secret. sig := "sha256=" + computeSig(body, "wrong-secret") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rec.Code) } } // TestWebhook_MissingHeader401 verifies that omitting X-Cal-Signature-256 // entirely returns 401 Unauthorized. func TestWebhook_MissingHeader401(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "supersecret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } body := bookingEventBody("BOOKING_CREATED", "u5", "Anon", "Meeting", "2026-05-27T14:00:00Z") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) // Intentionally no X-Cal-Signature-256 header. rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", rec.Code) } } // TestWebhook_MissingSecret503 verifies that when no webhook secret has been // configured, the handler returns 503 Service Unavailable. func TestWebhook_MissingSecret503(t *testing.T) { h, _ := newTestWebhookHandler(t) // Deliberately do NOT call SetWebhookSecret — secret is absent. body := bookingEventBody("BOOKING_CREATED", "u6", "Nobody", "Call", "2026-05-27T15:00:00Z") sig := "sha256=" + computeSig(body, "anything") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Errorf("expected 503, got %d", rec.Code) } } // ------------------------------------------------------------------------- // WO-048 — dispatch + unique key + last-event // ------------------------------------------------------------------------- // notifyCall captures a single invocation of the notifier. type notifyCall struct { severity, title, message, uniqueKey string } // captureNotifier installs a capturing notifier and returns a pointer to the // slice of calls. The caller MUST register t.Cleanup(func() { SetNotifyFn(nil) }). func captureNotifier(t *testing.T) *[]notifyCall { t.Helper() calls := &[]notifyCall{} SetNotifyFn(func(_ context.Context, sev, title, msg, key string) { *calls = append(*calls, notifyCall{sev, title, msg, key}) }) t.Cleanup(func() { SetNotifyFn(nil) }) return calls } // TestWebhook_BookingCreatedNotification verifies that a BOOKING_CREATED event // fires a notification with the correct title, attendee name, event title, and // unique key. func TestWebhook_BookingCreatedNotification(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) body := bookingEventBody("BOOKING_CREATED", "u1", "Bob", "Quick chat", "2026-05-27T10:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if len(*calls) != 1 { t.Fatalf("expected 1 notifier call, got %d", len(*calls)) } c := (*calls)[0] if c.severity != "info" { t.Errorf("expected severity 'info', got %q", c.severity) } if c.title != "New booking" { t.Errorf("expected title 'New booking', got %q", c.title) } if !strings.Contains(c.message, "Bob") { t.Errorf("expected message to contain 'Bob', got %q", c.message) } if !strings.Contains(c.message, "Quick chat") { t.Errorf("expected message to contain 'Quick chat', got %q", c.message) } if c.uniqueKey != "calcom:BOOKING_CREATED:u1" { t.Errorf("expected uniqueKey 'calcom:BOOKING_CREATED:u1', got %q", c.uniqueKey) } } // TestWebhook_BookingRescheduledNotification verifies that BOOKING_RESCHEDULED // produces the correct notification title and unique key. func TestWebhook_BookingRescheduledNotification(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) body := bookingEventBody("BOOKING_RESCHEDULED", "u2", "Carol", "Check-in", "2026-05-28T09:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if len(*calls) != 1 { t.Fatalf("expected 1 notifier call, got %d", len(*calls)) } c := (*calls)[0] if c.severity != "info" { t.Errorf("expected severity 'info', got %q", c.severity) } if c.title != "Booking rescheduled" { t.Errorf("expected title 'Booking rescheduled', got %q", c.title) } if c.uniqueKey != "calcom:BOOKING_RESCHEDULED:u2" { t.Errorf("expected uniqueKey 'calcom:BOOKING_RESCHEDULED:u2', got %q", c.uniqueKey) } } // TestWebhook_BookingCancelledNotification verifies that BOOKING_CANCELLED // produces the correct notification title and unique key. func TestWebhook_BookingCancelledNotification(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) body := bookingEventBody("BOOKING_CANCELLED", "u3", "Dave", "Strategy", "2026-05-29T14:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if len(*calls) != 1 { t.Fatalf("expected 1 notifier call, got %d", len(*calls)) } c := (*calls)[0] if c.severity != "info" { t.Errorf("expected severity 'info', got %q", c.severity) } if c.title != "Booking cancelled" { t.Errorf("expected title 'Booking cancelled', got %q", c.title) } if c.uniqueKey != "calcom:BOOKING_CANCELLED:u3" { t.Errorf("expected uniqueKey 'calcom:BOOKING_CANCELLED:u3', got %q", c.uniqueKey) } } // TestWebhook_UnknownEventIgnored verifies that an unrecognised triggerEvent // returns 200 but does NOT invoke the notifier — unknown events are silently // dropped so future Cal.com additions don't cause noisy notifications. func TestWebhook_UnknownEventIgnored(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) body := bookingEventBody("SOMETHING_ELSE", "u4", "Nobody", "Nothing", "2026-05-27T10:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Errorf("expected 200, got %d", rec.Code) } if len(*calls) != 0 { t.Errorf("expected notifier NOT called for unknown event, got %d calls", len(*calls)) } } // TestWebhook_NilNotifierSafe verifies that a nil notifier does not panic — // the handler must guard against an unregistered notifier gracefully. func TestWebhook_NilNotifierSafe(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } SetNotifyFn(nil) t.Cleanup(func() { SetNotifyFn(nil) }) body := bookingEventBody("BOOKING_CREATED", "u5", "Safe", "Call", "2026-05-27T10:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Errorf("expected 200, got %d", rec.Code) } } // TestWebhook_MalformedJson200 verifies that a correctly signed payload that // is not valid JSON returns 200 (Stripe pattern: ack the delivery so Cal.com // does not flood retries with junk). The notifier must NOT be called. func TestWebhook_MalformedJson200(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) body := []byte(`{this is not valid JSON`) sig := "sha256=" + computeSig(body, "s") req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req.Header.Set("X-Cal-Signature-256", sig) rec := httptest.NewRecorder() h.Handle(rec, req) if rec.Code != http.StatusOK { t.Errorf("expected 200 for malformed JSON, got %d", rec.Code) } if len(*calls) != 0 { t.Errorf("expected notifier NOT called for malformed JSON, got %d calls", len(*calls)) } } // TestWebhook_UniqueKeyFormat verifies that the uniqueKey passed to the // notifier follows the "calcom:{triggerEvent}:{uid}" format. This is the // key that the downstream NotificationService uses for deduplication, so // the format must be exact. (Replay dedup itself occurs inside // NotificationService.Emit via the database — not within this handler.) func TestWebhook_UniqueKeyFormat(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) // Send the same payload twice; both should carry the same uniqueKey. body := bookingEventBody("BOOKING_CREATED", "replay-uid", "Tester", "Dedup Test", "2026-05-27T10:00:00Z") sendSigned(t, h, body, "s") sendSigned(t, h, body, "s") if len(*calls) != 2 { t.Fatalf("expected 2 notifier calls (handler does not dedup), got %d", len(*calls)) } expected := "calcom:BOOKING_CREATED:replay-uid" for i, c := range *calls { if c.uniqueKey != expected { t.Errorf("call[%d]: expected uniqueKey %q, got %q", i, expected, c.uniqueKey) } } } // TestWebhookHandler_RecoversFromDispatchPanic asserts the handler swallows // notifier panics and still returns 200 to Cal.com — Stripe-pattern: a panic // IS an app-level error and must not surface as a 5xx that triggers Cal.com's // retry storm. func TestWebhookHandler_RecoversFromDispatchPanic(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } SetNotifyFn(func(_ context.Context, _, _, _, _ string) { panic("boom") }) t.Cleanup(func() { SetNotifyFn(nil) }) body := bookingEventBody("BOOKING_CREATED", "u-panic", "Boom", "Crash Test", "2026-05-27T10:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Errorf("expected 200 even when notifier panics, got %d", rec.Code) } } // TestWebhookHandler_ParsesRealCalcomPayloadShape locks down the JSON shape the // implementer's WebhookPayload struct assumes Cal.com sends. The payload below // is the implementer's CURRENT assumption — embedded inline rather than read // from a fixture file because WO-051 (capture a real Cal.com webhook in // staging) has not been run yet. // // After WO-051 runs, replace the assumedPayload literal with the real captured // JSON (load it from testdata/calcom_webhook_real.json) to confirm the shape // has not drifted. func TestWebhookHandler_ParsesRealCalcomPayloadShape(t *testing.T) { // IMPORTANT: this payload is the implementer's assumption. Replace with a // real Cal.com capture after WO-051. assumedPayload := []byte(`{ "triggerEvent": "BOOKING_CREATED", "createdAt": "2026-05-27T10:00:00Z", "payload": { "uid": "real-uid-abc123", "startTime": "2026-06-01T14:00:00Z", "endTime": "2026-06-01T14:30:00Z", "eventType": { "title": "Strategy Call", "slug": "strategy-call" }, "attendees": [ { "name": "Real Attendee", "email": "attendee@example.com", "timeZone": "America/Toronto" } ] } }`) h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "real-secret"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } calls := captureNotifier(t) rec := sendSigned(t, h, assumedPayload, "real-secret") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) } if len(*calls) != 1 { t.Fatalf("expected 1 notifier call, got %d", len(*calls)) } c := (*calls)[0] // uid → uniqueKey if c.uniqueKey != "calcom:BOOKING_CREATED:real-uid-abc123" { t.Errorf("uniqueKey: got %q, want calcom:BOOKING_CREATED:real-uid-abc123", c.uniqueKey) } // eventType.title → message if !strings.Contains(c.message, "Strategy Call") { t.Errorf("expected message to contain eventType.title 'Strategy Call', got %q", c.message) } // attendees[0].name → message if !strings.Contains(c.message, "Real Attendee") { t.Errorf("expected message to contain attendees[0].name 'Real Attendee', got %q", c.message) } // startTime → message (the implementer formats this verbatim) if !strings.Contains(c.message, "2026-06-01T14:00:00Z") { t.Errorf("expected message to contain startTime '2026-06-01T14:00:00Z', got %q", c.message) } // title (the notification title, not eventType.title) is "New booking" if c.title != "New booking" { t.Errorf("notification title: got %q, want 'New booking'", c.title) } // Also confirm last_event_at picked up the createdAt timestamp. s, err := settings.GetSettings(context.Background()) if err != nil { t.Fatalf("GetSettings: %v", err) } if got, _ := s["last_event_at"].(string); got != "2026-05-27T10:00:00Z" { t.Errorf("last_event_at: got %q, want '2026-05-27T10:00:00Z'", got) } } // TestWebhook_UpdatesLastEventInSettings verifies that after a successful // BOOKING_CREATED event, the settings store records last_event_type and // last_event_at so the admin panel can surface the last webhook activity. func TestWebhook_UpdatesLastEventInSettings(t *testing.T) { h, settings := newTestWebhookHandler(t) if err := settings.SetWebhookSecret(context.Background(), "s"); err != nil { t.Fatalf("SetWebhookSecret: %v", err) } // Notifier must be set so dispatch() proceeds; discard the call. SetNotifyFn(func(_ context.Context, _, _, _, _ string) {}) t.Cleanup(func() { SetNotifyFn(nil) }) body := bookingEventBody("BOOKING_CREATED", "u7", "Last", "Event Test", "2026-06-01T08:00:00Z") rec := sendSigned(t, h, body, "s") if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } s, err := settings.GetSettings(context.Background()) if err != nil { t.Fatalf("GetSettings: %v", err) } lastType, _ := s["last_event_type"].(string) if lastType != "BOOKING_CREATED" { t.Errorf("expected last_event_type 'BOOKING_CREATED', got %q", lastType) } lastAt, _ := s["last_event_at"].(string) if lastAt == "" { t.Errorf("expected last_event_at to be populated, got empty string") } }