calcomblock/webhook_test.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

543 lines
19 KiB
Go

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=<hex>" 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=<hex>" 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 "<hex>" 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")
}
}