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>
1336 lines
51 KiB
Go
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)])
|
|
}
|
|
}
|