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>
543 lines
19 KiB
Go
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")
|
|
}
|
|
}
|