calcomblock/handler_integration_test.go
Alex Dunmow 324a2e6fe0 chore: migrate from block/core to block/pluginsdk (keep core for captcha)
Rewrite imports for plugin, blocks, templates, auth, settings, crypto, and
rbac packages from git.dev.alexdunmow.com/block/core/* to
git.dev.alexdunmow.com/block/pluginsdk/* across all Go files. The captcha
package stays on block/core, so the repo keeps both requires.

go.mod: add pluginsdk v0.1.0; bump core v0.20.2 -> v0.20.3 (satisfies the
check-safety core-version gate); transitive churn pgx v5.9.2 -> v5.10.0 and
x/mod v0.34.0 -> v0.37.0 from go mod tidy.

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

1336 lines
51 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.dev.alexdunmow.com/block/pluginsdk/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// newTestRouter builds a chi.Router wired exactly like NewCalcomRouter but with
// an in-memory querier and isolated rate limiter — no DB pool required. Tests
// drive HTTP requests through this to exercise the middleware stack
// (requireAdmin in particular).
func newTestRouter(t *testing.T) http.Handler {
t.Helper()
settings, _ := newTestSettings()
limiter := NewRateLimiter(bookingsPerHourPerIP)
h := NewCalcomHandler(settings, limiter, "https://test.blockninja.dev")
r := chi.NewRouter()
r.Get("/slots", h.HandleGetSlots)
r.Get("/form", h.HandleGetForm)
r.Post("/book", h.HandleCreateBooking)
r.Post("/cancel", h.HandleCancelBooking)
r.Get("/reset", h.HandleReset)
r.Get("/date-grid", h.HandleGetDateGrid)
wh := NewWebhookHandler(settings, slog.Default())
r.Post("/webhook", wh.Handle)
r.Group(func(admin chi.Router) {
admin.Use(requireAdmin)
admin.Get("/settings", h.HandleGetSettings)
admin.Post("/settings", h.HandleSaveSettings)
admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
admin.Post("/test", h.HandleTestConnection)
admin.Get("/event-types", h.HandleListEventTypes)
})
return r
}
// adminCtx returns a context carrying admin-role claims so requireAdmin allows
// the request through.
func adminCtx(ctx context.Context) context.Context {
return auth.WithUser(ctx, &auth.Claims{
UserID: uuid.New(),
Email: "admin@example.test",
Role: "admin",
})
}
// viewerCtx returns a context carrying viewer-role claims — requireAdmin must
// reject this with 403 (not 401 — the user is authenticated, just not
// privileged enough).
func viewerCtx(ctx context.Context) context.Context {
return auth.WithUser(ctx, &auth.Claims{
UserID: uuid.New(),
Email: "viewer@example.test",
Role: "viewer",
})
}
// withCalcomBaseURL overrides the package-level Cal.com base URL for the
// duration of one test, restoring it on test cleanup. This is the seam that
// lets the public handlers (HandleGetSlots, HandleCreateBooking, HandleGetForm,
// HandleTestConnection, HandleListEventTypes) be exercised against an
// httptest.NewServer instead of api.cal.com.
//
// The handlers call NewCalcomClient(apiKey) inline, and NewCalcomClient reads
// calcomBaseURL when it constructs the client struct, so swapping the var
// reroutes every public handler at once with no further injection.
//
// Tests calling this helper must NOT run with t.Parallel — the override is a
// package-level var and would race across parallel goroutines.
func withCalcomBaseURL(t *testing.T, url string) {
t.Helper()
prev := calcomBaseURL
calcomBaseURL = url
// The event-timezone cache is keyed by username|slug, not base URL — wipe
// it on entry and exit so per-test fake servers can't leak zones into
// each other.
resetEventTZCache()
t.Cleanup(func() {
calcomBaseURL = prev
resetEventTZCache()
})
}
// ---------------------------------------------------------------------------
// Group A — Integration tests: handler → fake Cal.com via calcomBaseURL swap
// ---------------------------------------------------------------------------
// TestHandleGetSlots_HappyPath drives HandleGetSlots end-to-end against a fake
// Cal.com server. Asserts the rendered HTML carries the 12-hour formatted slot
// time, AND verifies the outbound request: path, query string, and the
// per-endpoint cal-api-version header.
func TestHandleGetSlots_HappyPath(t *testing.T) {
var (
gotPath string
gotQuery url.Values
gotHeaders http.Header
)
// The event's availability schedule is America/Toronto; the /slots
// passthrough captures the outbound request for assertion.
fakeCalcom(t, "America/Toronto", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.Query()
gotHeaders = r.Header
// Real cal-api-version 2024-09-04 response shape: each slot carries a
// `start` field (NOT the legacy 2024-08-13 `time` field). Millisecond
// precision mirrors what api.cal.com actually returns on the wire.
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"2026-06-01":[{"start":"2026-06-01T13:00:00.000Z"},{"start":"2026-06-01T15:30:00.000Z"}]
}
}`)
})
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/slots?username=alice&eventType=30min&date=2026-06-01&blockId=b1", nil)
rec := httptest.NewRecorder()
h.HandleGetSlots(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// 12-hour formatted times converted into the MEETING's timezone: 13:00Z /
// 15:30Z on June 1 are 9:00 AM / 11:30 AM in America/Toronto (EDT, UTC-4).
if !strings.Contains(body, "9:00 AM") {
t.Errorf("expected '9:00 AM' (13:00Z in America/Toronto) in body, got: %q", body)
}
if !strings.Contains(body, "11:30 AM") {
t.Errorf("expected '11:30 AM' (15:30Z in America/Toronto) in body, got: %q", body)
}
// Regression guard: the widget must NOT render the empty-state when Cal.com
// returned slots. This is the symptom of the 2024-09-04 `start`-vs-`time`
// field mismatch — every slot silently dropped, empty list, "No available
// times" despite a full slate from the API.
if strings.Contains(body, "No available times") {
t.Errorf("rendered empty-state despite Cal.com returning slots (slot-field shape mismatch); body: %q", body)
}
// Outbound-request assertions.
if gotPath != "/slots" {
t.Errorf("outbound path: got %q, want /slots", gotPath)
}
if got := gotQuery.Get("username"); got != "alice" {
t.Errorf("outbound username: got %q, want alice", got)
}
if got := gotQuery.Get("eventTypeSlug"); got != "30min" {
t.Errorf("outbound eventTypeSlug: got %q, want 30min", got)
}
if got := gotQuery.Get("timeZone"); got != "America/Toronto" {
t.Errorf("outbound timeZone: got %q, want America/Toronto", got)
}
if got := gotHeaders.Get("cal-api-version"); got != versionSlots {
t.Errorf("cal-api-version header: got %q, want %q", got, versionSlots)
}
if got := gotHeaders.Get("Authorization"); got != "Bearer cal_live_test" {
t.Errorf("Authorization header: got %q, want Bearer cal_live_test", got)
}
}
// TestHandleCreateBooking_HappyPath drives a successful booking end-to-end:
// POST to /book with a valid form → fake Cal.com returns a booking record →
// handler renders the confirmation panel including the meeting/reschedule/cancel
// links.
func TestHandleCreateBooking_HappyPath(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Real 2026-02-25 bookings response: `location` is the canonical meeting
// field; there are NO rescheduleUrl/cancelUrl fields (manage links reach
// the attendee via Cal.com's confirmation email).
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"id":123,
"uid":"booking-uid-123",
"start":"2026-06-01T10:00:00Z",
"end":"2026-06-01T10:30:00Z",
"location":"https://meet.example.com/booking-uid-123"
}
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"phone": {"+1-555-1212"},
"timezone": {"America/Toronto"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Booking Confirmed!") {
t.Errorf("expected 'Booking Confirmed!' in confirmation HTML, got: %q", body)
}
// Meeting link is derived from the booking's `location` field (2026-02-25),
// NOT the deprecated `meetingUrl`.
if !strings.Contains(body, "https://meet.example.com/booking-uid-123") {
t.Errorf("expected meeting link (from `location`) in body, got: %q", body)
}
if !strings.Contains(body, "bob@example.com") {
t.Errorf("expected attendee email in confirmation, got: %q", body)
}
// Regression guard (F1): the API returns no rescheduleUrl/cancelUrl, so the
// panel must not render those management links.
if strings.Contains(body, "Reschedule") {
t.Errorf("confirmation must not render a Reschedule link (API returns no rescheduleUrl); body: %q", body)
}
}
// TestHandleCreateBooking_NormalizesStartToUTC guards F4: Cal.com's bookings
// endpoint requires `start` in UTC. A slot start submitted with a non-UTC
// offset must be converted to UTC before it is sent upstream.
func TestHandleCreateBooking_NormalizesStartToUTC(t *testing.T) {
var gotBody string
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u","location":"https://meet.example.com/u"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-05-27T11:00:00+02:00"}, // 11:00 GMT+2 (Rome) == 09:00 UTC
"name": {"Bob"},
"email": {"bob@example.com"},
"timezone": {"Europe/Rome"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if !strings.Contains(gotBody, `"start":"2026-05-27T09:00:00Z"`) {
t.Errorf("outbound `start` not normalized to UTC; got body: %s", gotBody)
}
}
// TestHandleCreateBooking_HoneypotMakesZeroCalcomCalls verifies the spec-mandated
// property that a tripped honeypot ("bn_message_extra" field) short-circuits the request
// — the fake Cal.com server fails the test if it is ever called.
func TestHandleCreateBooking_HoneypotMakesZeroCalcomCalls(t *testing.T) {
var hits int64
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
t.Errorf("fake Cal.com was hit despite honeypot trip: %s %s", r.Method, r.URL.Path)
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"should-not-happen"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bot"},
"email": {"bot@spam.example.com"},
"bn_message_extra": {"spam"}, // honeypot tripped
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (fake confirmation), got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Booking Confirmed") {
t.Errorf("expected fake confirmation rendered, got: %q", rec.Body.String())
}
if got := atomic.LoadInt64(&hits); got != 0 {
t.Errorf("fake Cal.com received %d requests; honeypot must make ZERO upstream calls", got)
}
}
// captureBookingRequest inspects the Cal.com /bookings POST body and stores the
// parsed JSON for assertions.
type capturedBooking struct {
mu sync.Mutex
body map[string]any
}
func (c *capturedBooking) handler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Non-booking traffic — the event-timezone lookup (/schedules,
// /event-types) — is served a Perth schedule and not captured, so
// these tests exercise the same meeting-timezone resolution the
// production flow uses.
if !strings.HasPrefix(r.URL.Path, "/bookings") {
if strings.HasPrefix(r.URL.Path, "/schedules") {
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"Australia/Perth","isDefault":true}]}`)
return
}
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`)
return
}
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
c.mu.Lock()
defer c.mu.Unlock()
if err := json.Unmarshal(raw, &c.body); err != nil {
t.Errorf("decode body: %v (raw: %s)", err, string(raw))
}
_, _ = io.WriteString(w, `{
"status":"success",
"data":{
"uid":"u",
"rescheduleUrl":"https://cal.com/reschedule/u",
"cancelUrl":"https://cal.com/booking/u?cancel=true"
}
}`)
}
}
// TestHandleCreateBooking_CustomFieldsLandInBookingFieldsResponses asserts the
// full payload shape sent to Cal.com:
// - Built-in fields go into attendee.{name,email,phoneNumber,timeZone}.
// - Custom fields (q1, q2) land in bookingFieldsResponses.
// - Built-ins MUST NOT appear in bookingFieldsResponses (the field-stripping
// contract documented in WO-014/045).
// - The honeypot field "bn_message_extra" must not appear anywhere.
func TestHandleCreateBooking_CustomFieldsLandInBookingFieldsResponses(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"phone": {"555-1212"},
// Stale cached pages still post a visitor timezone; it must not
// reach Cal.com — the attendee carries the meeting's timezone.
"timezone": {"America/Toronto"},
"q1": {"yes"},
"q2": {"blue"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
if cap.body == nil {
t.Fatalf("Cal.com received no parseable body")
}
att, ok := cap.body["attendee"].(map[string]any)
if !ok {
t.Fatalf("attendee missing/wrong type in body: %v", cap.body)
}
if att["name"] != "Bob" {
t.Errorf("attendee.name: got %v, want Bob", att["name"])
}
if att["email"] != "bob@example.com" {
t.Errorf("attendee.email: got %v, want bob@example.com", att["email"])
}
// Phone is E.164-normalized on the way out: separators stripped.
if att["phoneNumber"] != "5551212" {
t.Errorf("attendee.phoneNumber: got %v, want 5551212 (normalized)", att["phoneNumber"])
}
if att["timeZone"] != "Australia/Perth" {
t.Errorf("attendee.timeZone: got %v, want Australia/Perth (the meeting's schedule timezone, not the posted visitor field)", att["timeZone"])
}
resps, ok := cap.body["bookingFieldsResponses"].(map[string]any)
if !ok {
t.Fatalf("bookingFieldsResponses missing/wrong type in body: %v", cap.body)
}
if resps["q1"] != "yes" {
t.Errorf("bookingFieldsResponses.q1: got %v, want 'yes'", resps["q1"])
}
if resps["q2"] != "blue" {
t.Errorf("bookingFieldsResponses.q2: got %v, want 'blue'", resps["q2"])
}
// Built-in / honeypot keys must NEVER appear inside bookingFieldsResponses.
for _, banned := range []string{"name", "email", "phone", "bn_message_extra", "timezone", "blockId", "username", "eventType", "start"} {
if _, present := resps[banned]; present {
t.Errorf("bookingFieldsResponses must not contain built-in key %q (got value %v)", banned, resps[banned])
}
}
}
// TestHandleCreateBooking_MultiValueCustomField asserts that a multi-valued
// custom field (e.g. checkboxes with the same name) is sent as a JSON array,
// not the empty/comma-joined string that url.Values.Get would return.
func TestHandleCreateBooking_MultiValueCustomField(t *testing.T) {
cap := &capturedBooking{}
fake := httptest.NewServer(cap.handler(t))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"interests": {"ai", "cms"}, // multi-value custom field
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
cap.mu.Lock()
defer cap.mu.Unlock()
resps, ok := cap.body["bookingFieldsResponses"].(map[string]any)
if !ok {
t.Fatalf("bookingFieldsResponses missing/wrong type: %v", cap.body)
}
got, ok := resps["interests"].([]any)
if !ok {
t.Fatalf("bookingFieldsResponses.interests must be a JSON array, got %T: %v", resps["interests"], resps["interests"])
}
if len(got) != 2 || got[0] != "ai" || got[1] != "cms" {
t.Errorf("interests array wrong: %v", got)
}
}
// TestHandleCreateBooking_CalcomReturns4xxRendersCuratedError verifies that when
// Cal.com responds with a 4xx and a structured error message, the handler
// renders CURATED, visitor-facing copy rather than echoing the Cal.com message
// verbatim. The Cal.com message can carry implementation detail (slot IDs,
// internal references) and must not surface to the public visitor. Here the
// message signals a gone slot ("no longer available"), so the handler maps it to
// the specific slot-unavailable Tier-1 copy WITH a retry control.
func TestHandleCreateBooking_CalcomReturns4xxRendersCuratedError(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"message":"Slot no longer available — internal-ref 0xDEADBEEF"}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 with error body, got %d", rec.Code)
}
body := rec.Body.String()
// A gone-slot signal maps to the specific, actionable Tier-1 copy with a
// retry control — never the old generic "Booking failed".
if !strings.Contains(body, "isn") || !strings.Contains(body, "choose another slot") {
t.Errorf("expected curated slot-unavailable message in body, got: %q", body)
}
if !strings.Contains(body, "Try again") {
t.Errorf("expected recoverable slot error to render the retry control, got: %q", body)
}
if strings.Contains(body, "Booking failed") {
t.Errorf("handler still renders the old generic message; body=%q", body)
}
// The Cal.com upstream message — including the internal-ref leak — must
// NEVER reach the visitor.
if strings.Contains(body, "internal-ref") {
t.Errorf("response leaks Cal.com upstream detail 'internal-ref' to visitor; body=%q", body)
}
if strings.Contains(body, "Slot no longer available") {
t.Errorf("response echoes Cal.com message verbatim to visitor; body=%q", body)
}
}
// TestHandleCreateBooking_TierTwoUsesAdminMessage verifies the admin-authored
// Tier-2 path: an unrecognised / our-side Cal.com failure (here a 5xx) renders
// the per-block `unavailableMessage` from the form, never Cal.com's raw text,
// and WITHOUT a retry control (the visitor can't fix it by re-picking a slot).
func TestHandleCreateBooking_TierTwoUsesAdminMessage(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, `{"error":{"code":"INTERNAL_SERVER_ERROR","message":"upstream exploded — trace 0xCAFE"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
const adminMsg = "Please ring the clinic on 1234 to book."
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
"unavailableMessage": {adminMsg},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 with error body, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, adminMsg) {
t.Errorf("expected admin-authored Tier-2 message in body, got: %q", body)
}
if strings.Contains(body, "Try again") {
t.Errorf("non-recoverable Tier-2 error must NOT render a retry control; body=%q", body)
}
if strings.Contains(body, "trace") || strings.Contains(body, "upstream exploded") {
t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
}
}
// TestHandleCreateBooking_TierTwoDefaultMessage verifies that when the admin
// left `unavailableMessage` blank, an unrecognised failure falls back to
// DefaultUnavailableMessage (not Cal.com's raw text, not the old generic copy).
func TestHandleCreateBooking_TierTwoDefaultMessage(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Event type with slug nope not found"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 with error body, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "use our contact form") {
t.Errorf("expected DefaultUnavailableMessage in body, got: %q", body)
}
if strings.Contains(body, "not found") || strings.Contains(body, "Event type with slug") {
t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
}
}
// TestHandleGetForm_FetchesBookingFieldsFromCalcom drives HandleGetForm to fetch
// custom bookingFields from Cal.com (via the GetEventType API) and assert the
// rendered form contains both the textarea and the select with all options.
// Also confirms the per-endpoint cal-api-version header is correct.
func TestHandleGetForm_FetchesBookingFieldsFromCalcom(t *testing.T) {
var gotHeaders http.Header
var gotQuery url.Values
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header
gotQuery = r.URL.Query()
_, _ = io.WriteString(w, `{
"status":"success",
"data":[{
"id":7,
"slug":"intake",
"title":"Intake",
"lengthInMinutes":15,
"bookingFields":[
{"slug":"size","label":"Company size","type":"select","required":true,
"options":[{"label":"1-10","value":"small"},{"label":"11-50","value":"medium"}]},
{"slug":"notes","label":"Notes","type":"textarea","required":false}
]
}]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=intake",
nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Company size") {
t.Errorf("expected 'Company size' label in form, got: %q", body)
}
if !strings.Contains(body, "Notes") {
t.Errorf("expected 'Notes' label in form, got: %q", body)
}
if !strings.Contains(body, `name="size"`) {
t.Errorf("expected select input name=\"size\" in form, got: %q", body)
}
if !strings.Contains(body, `value="small"`) || !strings.Contains(body, `value="medium"`) {
t.Errorf("expected both select options in form, got: %q", body)
}
if !strings.Contains(body, `name="notes"`) {
t.Errorf("expected textarea name=\"notes\" in form, got: %q", body)
}
if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
}
if got := gotQuery.Get("username"); got != "alice" {
t.Errorf("outbound username: got %q, want alice", got)
}
if got := gotQuery.Get("eventSlug"); got != "intake" {
t.Errorf("outbound eventSlug: got %q, want intake", got)
}
}
// TestHandleGetForm_OnFetchErrorFallsBackToDefaults verifies the WO-027
// fallback: if the Cal.com event-type fetch fails (5xx upstream), HandleGetForm
// still renders the form using the default `name` + `email` fields.
func TestHandleGetForm_OnFetchErrorFallsBackToDefaults(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"message":"Cal.com is on fire"}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet,
"/form?blockId=b1&start=2026-06-01T10:00:00Z&date=2026-06-01&username=alice&eventType=30min",
nil)
rec := httptest.NewRecorder()
h.HandleGetForm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (fallback form rendered), got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `name="name"`) {
t.Errorf("expected fallback name input in body, got: %q", body)
}
if !strings.Contains(body, `name="email"`) {
t.Errorf("expected fallback email input in body, got: %q", body)
}
if strings.Contains(body, "Cal.com is on fire") {
t.Errorf("response leaks upstream error message to visitor: %q", body)
}
}
// TestHandleTestConnection_HappyPath drives the admin /test endpoint against a
// fake Cal.com that returns the API-key owner's event types. Asserts the JSON
// success envelope, the rendered event_types list, and the version header.
func TestHandleTestConnection_HappyPath(t *testing.T) {
var gotHeaders http.Header
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header
_, _ = io.WriteString(w, `{
"status":"success",
"data":[
{"id":1,"slug":"30min","title":"30 Minute","lengthInMinutes":30},
{"id":2,"slug":"60min","title":"60 Minute","lengthInMinutes":60}
]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
rec := httptest.NewRecorder()
h.HandleTestConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if success, _ := resp["success"].(bool); !success {
t.Errorf("expected success=true, got: %v", resp)
}
ets, ok := resp["event_types"].([]any)
if !ok {
t.Fatalf("event_types missing or wrong type: %v", resp)
}
if len(ets) != 2 {
t.Errorf("expected 2 event types in response, got %d", len(ets))
}
if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
}
}
// TestHandleListEventTypes_HappyPath drives the admin /event-types endpoint
// (the editor dropdown source) against a fake Cal.com. Same shape as
// HandleTestConnection but driven by GET /event-types?username=.
func TestHandleListEventTypes_HappyPath(t *testing.T) {
var gotQuery url.Values
var gotHeaders http.Header
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
gotHeaders = r.Header
_, _ = io.WriteString(w, `{
"status":"success",
"data":[
{"id":7,"slug":"intake","title":"Intake Call","lengthInMinutes":15}
]
}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/event-types?username=alice", nil)
rec := httptest.NewRecorder()
h.HandleListEventTypes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if success, _ := resp["success"].(bool); !success {
t.Errorf("expected success=true, got: %v", resp)
}
ets, ok := resp["event_types"].([]any)
if !ok {
t.Fatalf("event_types missing or wrong type: %v", resp)
}
if len(ets) != 1 {
t.Errorf("expected 1 event type in response, got %d", len(ets))
}
if got := gotQuery.Get("username"); got != "alice" {
t.Errorf("outbound username: got %q, want alice", got)
}
if got := gotHeaders.Get("cal-api-version"); got != versionEventTypes {
t.Errorf("cal-api-version header: got %q, want %q", got, versionEventTypes)
}
}
// ---------------------------------------------------------------------------
// Auth-hole regression — admin endpoints must 401 without admin context
// ---------------------------------------------------------------------------
//
// The plugin's HTTP routes mount under /api/plugins/calcomblock/* with no
// outer auth wrapper. Admin endpoints (settings, event-types, test, rotate)
// rely on the requireAdmin middleware in handler.go for their entire defence.
// This test drives requests through the full chi.Router via the plugin's
// HTTPHandler entry point and asserts each admin endpoint returns 401 when
// the request carries no admin claims, while public endpoints stay reachable.
// TestPluginRouter_AdminEndpointsRequireAuth confirms each admin endpoint
// returns 401 Unauthorized when an anonymous request reaches it. Public
// endpoints in the same router remain 200 (or whatever their own logic says).
//
// This is the critical regression guard: the adversarial review caught that
// these endpoints were unprotected before the requireAdmin middleware was
// added. If anyone disables the middleware, this test fails immediately.
func TestPluginRouter_AdminEndpointsRequireAuth(t *testing.T) {
router := newTestRouter(t)
cases := []struct {
name string
method string
path string
}{
{"GET /settings", http.MethodGet, "/settings"},
{"POST /settings", http.MethodPost, "/settings"},
{"POST /settings/rotate-webhook-secret", http.MethodPost, "/settings/rotate-webhook-secret"},
{"POST /test", http.MethodPost, "/test"},
{"GET /event-types", http.MethodGet, "/event-types"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(c.method, c.path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s without admin claims: expected 401, got %d (body: %q)", c.name, rec.Code, rec.Body.String())
}
})
}
}
// TestPluginRouter_PublicEndpointsStayOpen confirms the public booking routes
// and the HMAC-verified webhook are NOT gated by requireAdmin.
func TestPluginRouter_PublicEndpointsStayOpen(t *testing.T) {
router := newTestRouter(t)
cases := []struct {
name string
method string
path string
}{
// Public booking endpoints — should not return 401 (they may return
// other statuses depending on params, but never 401 from requireAdmin).
{"GET /reset", http.MethodGet, "/reset"},
{"GET /slots", http.MethodGet, "/slots?blockId=b1"},
{"GET /form", http.MethodGet, "/form?blockId=b1"},
// /book + /webhook are POST; the auth middleware would intercept GETs
// too, so test those by sending an empty POST and checking != 401.
{"POST /book", http.MethodPost, "/book"},
{"POST /webhook", http.MethodPost, "/webhook"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(c.method, c.path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusUnauthorized {
t.Errorf("%s should not be gated by requireAdmin (got 401)", c.name)
}
})
}
}
// TestPluginRouter_AdminEndpointReachableWithAdminClaims verifies a request
// carrying admin claims via auth.WithUser passes the requireAdmin gate.
func TestPluginRouter_AdminEndpointReachableWithAdminClaims(t *testing.T) {
router := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
req = req.WithContext(adminCtx(req.Context()))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("admin request to /settings: expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
}
}
// TestPluginRouter_AdminEndpointForbiddenForViewer verifies a request carrying
// a viewer-role session is rejected with 403 (not 200, not 401).
func TestPluginRouter_AdminEndpointForbiddenForViewer(t *testing.T) {
router := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
req = req.WithContext(viewerCtx(req.Context()))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("viewer request to /settings: expected 403, got %d (body: %q)", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Phase F — structural regressions for the visitor-widget polish pass
// ---------------------------------------------------------------------------
// TestHandleCreateBooking_ConfirmationEmitsOOBClears guards the structural
// fix introduced in Phase A: after a successful POST /book, the response
// body must include hx-swap-oob directives that empty the date grid, slots,
// and form regions plus advance the step indicator to step 4. Without this
// the visitor sees their populated form below the confirmation panel and
// can re-submit (duplicate booking footgun the adversarial review caught).
func TestHandleCreateBooking_ConfirmationEmitsOOBClears(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u-conf-1","rescheduleUrl":"https://cal.com/r/u-conf-1","cancelUrl":"https://cal.com/c/u-conf-1"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b-conf"},
"username": {"alice"},
"eventType": {"30min"},
"start": {"2026-06-01T10:00:00Z"},
"name": {"Bob"},
"email": {"bob@example.com"},
}
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCreateBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Booking Confirmed!") {
t.Errorf("expected confirmation in body, got: %q", body)
}
// The body must include OOB-clear directives for the three upstream regions
// plus an OOB step-indicator update marking everything complete.
wantSubstrings := []string{
`id="calcom-date-grid-b-conf"`,
`id="calcom-slots-b-conf"`,
`id="calcom-form-b-conf"`,
`id="calcom-steps-b-conf"`,
`hx-swap-oob="innerHTML"`,
`hx-swap-oob="true"`,
}
for _, want := range wantSubstrings {
if !strings.Contains(body, want) {
t.Errorf("response missing %q — confirmation must emit hx-swap-oob clears for the upstream regions", want)
}
}
}
// TestHandleReset_EmitsOOBFormClearAndStepRewind guards the "Change date"
// fix: GET /reset rewinds the step indicator and clears the form region in
// addition to emptying the slots target.
func TestHandleReset_EmitsOOBFormClearAndStepRewind(t *testing.T) {
h, _ := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/reset?blockId=b-reset", nil)
rec := httptest.NewRecorder()
h.HandleReset(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`id="calcom-form-b-reset"`,
`id="calcom-steps-b-reset"`,
`hx-swap-oob="innerHTML"`,
`hx-swap-oob="true"`,
} {
if !strings.Contains(body, want) {
t.Errorf("reset response missing %q", want)
}
}
}
// TestHandleGetDateGrid_NextWindow exercises Phase C month navigation: GET
// /date-grid with a future weekStart returns a re-rendered grid scoped to
// that window, includes the prev button (CanGoBack=true), and the range
// label reflects the window.
func TestHandleGetDateGrid_NextWindow(t *testing.T) {
router := newTestRouter(t)
// Pick a weekStart 4 weeks in the future so it's safely after today
// regardless of when the test runs.
future := time.Now().AddDate(0, 0, 28).Format("2006-01-02")
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-nav&username=alice&eventType=30min&weeks=2&weekStart="+future, nil)
req = req.WithContext(adminCtx(req.Context())) // route doesn't gate, but consistent
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Future window must show the prev button (aria-label="Previous dates").
if !strings.Contains(body, `aria-label="Previous dates"`) {
t.Errorf("future window response missing prev button: %q", body[:min(400, len(body))])
}
// Next button is always present.
if !strings.Contains(body, `aria-label="Next dates"`) {
t.Errorf("response missing next button: %q", body[:min(400, len(body))])
}
}
// TestHandleGetDateGrid_ClampsToToday guards the rule that visitors cannot
// page into the past. A weekStart from 2020 must be clamped to today, and
// the prev button must NOT render (CanGoBack=false).
func TestHandleGetDateGrid_ClampsToToday(t *testing.T) {
router := newTestRouter(t)
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-past&username=alice&eventType=30min&weeks=2&weekStart=2020-01-01", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, `aria-label="Previous dates"`) {
t.Errorf("clamped-to-today window must NOT render prev button (CanGoBack=false), body: %q", body[:min(400, len(body))])
}
if !strings.Contains(body, `aria-label="Next dates"`) {
t.Errorf("response missing next button: %q", body[:min(400, len(body))])
}
}
// TestGenerateDateGridFrom_PastDatesFlagged verifies block.go's IsPast
// computation: a window starting 2 days ago flags the first cell as past,
// today as not past.
func TestGenerateDateGridFrom_PastDatesFlagged(t *testing.T) {
start := time.Now().AddDate(0, 0, -2)
dates := generateDateGridFrom(start, 1, time.Now().Format("2006-01-02"))
if len(dates) != 7 {
t.Fatalf("expected 7 cells, got %d", len(dates))
}
if !dates[0].IsPast {
t.Errorf("first cell (2 days ago) should be IsPast=true: %+v", dates[0])
}
if !dates[1].IsPast {
t.Errorf("second cell (yesterday) should be IsPast=true: %+v", dates[1])
}
if dates[2].IsPast {
t.Errorf("third cell (today) should be IsPast=false: %+v", dates[2])
}
if !dates[2].IsToday {
t.Errorf("third cell should be IsToday=true: %+v", dates[2])
}
}
// TestCalcomBookingWidget_RendersAccessibilityScaffolding is the Phase F a11y
// regression: the widget root must carry aria-live="polite" on the three swap
// regions plus role="grid" on the date picker. If any of these regress the
// test fails and the safety net is intact.
func TestCalcomBookingWidget_RendersAccessibilityScaffolding(t *testing.T) {
router := newTestRouter(t)
// Reach the date grid via /date-grid (which renders calcomDateGrid alone)
// and confirm role="grid" is present.
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-a11y&username=alice&eventType=30min&weeks=1&weekStart="+time.Now().Format("2006-01-02"), nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `role="grid"`) {
t.Errorf(`date-grid partial missing role="grid"`)
}
if !strings.Contains(rec.Body.String(), `aria-label="Booking date picker"`) {
t.Errorf(`date-grid partial missing aria-label="Booking date picker"`)
}
}
// ---------------------------------------------------------------------------
// HandleCancelBooking — "manage your booking" cancel endpoint
// ---------------------------------------------------------------------------
// TestHandleCancelBooking_HappyPath drives a successful cancel end-to-end:
// POST /cancel with a uid → fake Cal.com accepts the cancel → handler renders
// the "Booking cancelled" partial. Also asserts the outbound request hit the
// confirmed v2 cancel path with the bookings cal-api-version.
func TestHandleCancelBooking_HappyPath(t *testing.T) {
var gotPath, gotVersion string
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotVersion = r.Header.Get("cal-api-version")
_, _ = io.WriteString(w, `{"status":"success","data":{"id":1,"uid":"booking-uid-123","status":"cancelled"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{
"blockId": {"b1"},
"uid": {"booking-uid-123"},
}
req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCancelBooking(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Booking cancelled") {
t.Errorf("expected 'Booking cancelled' in cancel HTML, got: %q", body)
}
// OOB rewind of the step indicator to step 1 so the rebook flow starts fresh.
if !strings.Contains(body, `id="calcom-steps-b1"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("expected OOB step-indicator rewind in cancel body, got: %q", body)
}
if gotPath != "/bookings/booking-uid-123/cancel" {
t.Errorf("upstream cancel path: got %q, want /bookings/booking-uid-123/cancel", gotPath)
}
if gotVersion != versionBookings {
t.Errorf("upstream cal-api-version: got %q, want %q", gotVersion, versionBookings)
}
}
// TestHandleCancelBooking_MissingUIDMakesZeroCalcomCalls verifies that a cancel
// request with no uid renders an error and never reaches Cal.com (the fake fails
// the test if it is ever called).
func TestHandleCancelBooking_MissingUIDMakesZeroCalcomCalls(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("Cal.com must NOT be called when uid is missing; got %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{"blockId": {"b1"}} // no uid
req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCancelBooking(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "Missing booking reference") {
t.Errorf("expected 'Missing booking reference' error, got: %q", body)
}
if strings.Contains(body, "Booking cancelled") {
t.Errorf("must not render success on a missing uid; body=%q", body)
}
}
// TestHandleCancelBooking_CalcomErrorRendersCuratedRetry verifies that when
// Cal.com rejects the cancel (e.g. 404), the handler renders curated copy WITH a
// retry control and never echoes Cal.com's raw message to the visitor.
func TestHandleCancelBooking_CalcomErrorRendersCuratedRetry(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"error":{"code":"NotFoundException","message":"Booking not found — ref 0xBADF00D"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
h, _ := newTestHandler()
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
form := url.Values{"blockId": {"b1"}, "uid": {"gone-uid"}}
req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.HandleCancelBooking(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 with error body, got %d", rec.Code)
}
body := rec.Body.String()
// templ HTML-escapes the apostrophe in "couldn't", so match the parts that
// survive escaping rather than the raw apostrophe.
if !strings.Contains(body, "cancel that booking") {
t.Errorf("expected curated cancel-failure copy, got: %q", body)
}
if !strings.Contains(body, "Try again") {
t.Errorf("expected a retry control on a failed cancel, got: %q", body)
}
if strings.Contains(body, "0xBADF00D") || strings.Contains(body, "Booking not found") {
t.Errorf("response leaks Cal.com upstream detail to visitor; body=%q", body)
}
}
// TestHandleCancelBooking_RateLimited verifies the IP rate limit guards the
// cancel endpoint (the abuse guard for UID-based cancellation). A limiter with a
// budget of 1 lets the first cancel through and 429s the second from the same IP.
func TestHandleCancelBooking_RateLimited(t *testing.T) {
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"status":"success","data":{"status":"cancelled"}}`)
}))
t.Cleanup(fake.Close)
withCalcomBaseURL(t, fake.URL)
settings, _ := newTestSettings()
if err := settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
t.Fatalf("SetAPIKey: %v", err)
}
h := NewCalcomHandler(settings, NewRateLimiter(1), "https://test.blockninja.dev")
doCancel := func() *httptest.ResponseRecorder {
form := url.Values{"blockId": {"b1"}, "uid": {"some-uid"}}
req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.7:5555" // same IP both calls
rec := httptest.NewRecorder()
h.HandleCancelBooking(rec, req)
return rec
}
if rec := doCancel(); rec.Code != http.StatusOK {
t.Fatalf("first cancel: expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
rec := doCancel()
if rec.Code != http.StatusTooManyRequests {
t.Errorf("second cancel from same IP: expected 429, got %d", rec.Code)
}
}
// TestCalcomBookingWidget_RendersSavedBookingPanelAndPersistMarkup verifies the
// "manage your booking" scaffolding is present in the server-rendered widget:
// the normally-hidden saved-booking panel (with its cancel form posting /cancel
// and the redo button) and the stable-key derivation in the widget script.
func TestCalcomBookingWidget_RendersSavedBookingPanelAndPersistMarkup(t *testing.T) {
cfg := BookingConfig{
BlockID: "b-saved",
Username: "alice",
EventTypeSlug: "30min",
WeeksToShow: 1,
WeekStart: time.Now().Format("2006-01-02"),
Today: time.Now().Format("2006-01-02"),
TimeZone: "UTC",
Dates: generateDateGridFrom(time.Now(), 1, time.Now().Format("2006-01-02")),
}
var buf bytes.Buffer
if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil {
t.Fatalf("render widget: %v", err)
}
html := buf.String()
for _, want := range []string{
`id="calcom-saved-b-saved"`, // saved-booking panel container
`data-calcom-saved`, // panel marker the script toggles
`hx-post="/api/plugins/calcomblock/cancel"`, // cancel form posts the new route
`name="uid"`, // hidden uid the script fills from localStorage
`data-calcom-redo`, // reschedule/redo button
`calcom:booking:`, // stable localStorage key prefix in the script
} {
if !strings.Contains(html, want) {
t.Errorf("widget missing %q", want)
}
}
// The saved panel must start hidden (revealed client-side only when storage holds a booking).
if !strings.Contains(html, `calcom-saved-booking hidden`) {
t.Errorf("saved-booking panel must render hidden by default; html=%q", html[:min(len(html), 400)])
}
}