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/core/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.localdev.blockninjacms.com") 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.localdev.blockninjacms.com") 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)]) } }