Convert the bundled backend/internal/plugins/calcomblock package into a standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms import (forbidden in standalone plugins): - package calcomblock -> package main; add wasip1 main.go with wasmguest.Serve(Registration). go.mod module git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace directives. - Settings persistence: replace the pool-backed poolQuerier (direct SQL against the public-schema `settings` table, which a sandboxed plugin role cannot read) with capabilityQuerier over the SDK settings.Settings / settings.Updater capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side. - Vendor the small CMS-internal helpers the plugin used into internal/helpers (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier settings helpers, PluginCrypto for tests) and internal/db (minimal Setting / UpsertSettingParams), each with a provenance header. - Inline the captcha widget: vendor blocks.CaptchaWidget as a local CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the block/cms/blocks import from booking.templ. - blockConfig (server-authoritative captcha requirement via a page_block_snapshots scan) has no wasm-ABI capability, so it is left nil: honeypot + per-IP rate limit still apply. Documented as a follow-up. - web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js repointed at ../../../cms/web/eslint.config.js; rebuild web/dist. - Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore. Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp; check-safety passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
401 lines
16 KiB
Go
401 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// These tests pin the timezone contract for the public booking flow: every
|
|
// surface (slot list, form recap, confirmation, attendee record) renders in
|
|
// the MEETING's timezone — the event's Cal.com availability schedule — never
|
|
// the visitor's. Ladder: event schedule timezone → site Localization setting
|
|
// → UTC.
|
|
//
|
|
// Written against the bidbuddy.com.au incidents (2026-06-10). First: the
|
|
// handler windowed and rendered slots in UTC (Perth visitors saw "12:00 AM"),
|
|
// leaked the next day (Cal.com's end bound is date-granular), and assembled
|
|
// slots from an unsorted map range. Second: display depended on client-side
|
|
// timezone detection, which shipped a never-updated "UTC" sentinel on cached
|
|
// pages. The meeting timezone is a server-side fact; visitor state is out.
|
|
|
|
// seedSiteTimezone writes a site settings blob with the given localization
|
|
// timezone into the fake querier, mirroring Site Settings → Localization.
|
|
func seedSiteTimezone(q *fakeQuerier, tz string) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
q.data["site"] = []byte(`{"localization":{"timezone":"` + tz + `"}}`)
|
|
}
|
|
|
|
// fakeCalcom builds a Cal.com test double serving the event-timezone
|
|
// resolution pair — /event-types (scheduleId: null → default schedule) and
|
|
// /schedules (the schedule carrying scheduleTZ) — and delegates every other
|
|
// path (slots, bookings) to rest. scheduleTZ "" makes /schedules fail with a
|
|
// 500, simulating Cal.com being unreachable for the timezone lookup. The
|
|
// double is installed as the package base URL for the duration of the test.
|
|
func fakeCalcom(t *testing.T, scheduleTZ string, rest http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/event-types"):
|
|
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":1,"slug":"30min","scheduleId":null,"bookingFields":[]}]}`)
|
|
case strings.HasPrefix(r.URL.Path, "/schedules"):
|
|
if scheduleTZ == "" {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"status":"success","data":[{"id":9,"name":"Availability","timeZone":"`+scheduleTZ+`","isDefault":true}]}`)
|
|
default:
|
|
if rest != nil {
|
|
rest(w, r)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
withCalcomBaseURL(t, srv.URL)
|
|
return srv
|
|
}
|
|
|
|
// newKeyedHandler returns a handler whose settings carry an API key — the
|
|
// precondition for the event-timezone fetch.
|
|
func newKeyedHandler(t *testing.T) (*CalcomHandler, *fakeQuerier) {
|
|
t.Helper()
|
|
h, q := newTestHandler()
|
|
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
return h, q
|
|
}
|
|
|
|
// TestHandleGetSlots_WindowBuiltInEventTimezone asserts the [start, end)
|
|
// window sent to Cal.com covers the selected date in the MEETING's timezone —
|
|
// resolved from the event's availability schedule, with no timezone hint on
|
|
// the request at all. For date=2026-06-11 and a Perth schedule the window
|
|
// must be 2026-06-11T00:00:00+08:00 .. 2026-06-12T00:00:00+08:00.
|
|
func TestHandleGetSlots_WindowBuiltInEventTimezone(t *testing.T) {
|
|
var gotQuery url.Values
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&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())
|
|
}
|
|
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
|
|
t.Errorf("outbound start: got %q, want 2026-06-11T00:00:00+08:00", got)
|
|
}
|
|
if got := gotQuery.Get("end"); got != "2026-06-12T00:00:00+08:00" {
|
|
t.Errorf("outbound end: got %q, want 2026-06-12T00:00:00+08:00", got)
|
|
}
|
|
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
|
|
t.Errorf("outbound timeZone: got %q, want Australia/Perth", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_VisitorTimezoneParamIgnored pins the design decision: a
|
|
// timezone query param — whether a visitor's real browser zone or a stale
|
|
// cached page's sentinel — must NOT move the display away from the meeting's
|
|
// timezone. Availability is a fact about the host's calendar.
|
|
func TestHandleGetSlots_VisitorTimezoneParamIgnored(t *testing.T) {
|
|
var gotQuery url.Values
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1&timezone=America/New_York", 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())
|
|
}
|
|
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
|
|
t.Errorf("outbound timeZone: got %q, want Australia/Perth (visitor param must be ignored)", got)
|
|
}
|
|
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
|
|
t.Errorf("outbound start: got %q, want Perth-midnight window despite visitor param", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_FiltersSlotsOutsideEventDay reproduces the production
|
|
// leak: Cal.com treats the end bound date-granularly and returns the entire
|
|
// next day too. Slots not falling on the requested date in the meeting's
|
|
// timezone must be dropped, not rendered under the wrong heading (where
|
|
// same-wall-clock times read as duplicates).
|
|
func TestHandleGetSlots_FiltersSlotsOutsideEventDay(t *testing.T) {
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
// Perth-local response shape with a leaked next-day group, as observed
|
|
// live on 2026-06-10 against api.cal.com.
|
|
_, _ = io.WriteString(w, `{
|
|
"status":"success",
|
|
"data":{
|
|
"2026-06-11":[{"start":"2026-06-11T08:00:00.000+08:00"},{"start":"2026-06-11T17:30:00.000+08:00"}],
|
|
"2026-06-12":[{"start":"2026-06-12T13:30:00.000+08:00"}]
|
|
}
|
|
}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "8:00 AM") {
|
|
t.Errorf("expected '8:00 AM' (Perth-local slot) in body, got: %q", body)
|
|
}
|
|
if !strings.Contains(body, "5:30 PM") {
|
|
t.Errorf("expected '5:30 PM' (Perth-local slot) in body, got: %q", body)
|
|
}
|
|
if strings.Contains(body, "1:30 PM") {
|
|
t.Errorf("next-day slot (2026-06-12 13:30) leaked into the 2026-06-11 view: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone asserts display times are
|
|
// converted into the meeting's timezone even when Cal.com returns UTC (Z)
|
|
// timestamps — display must never depend on the offset Cal.com happens to
|
|
// format with.
|
|
func TestHandleGetSlots_ConvertsUTCSlotsToEventTimezone(t *testing.T) {
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
// 2026-06-11 00:00Z == 8:00 AM Perth; 09:30Z == 5:30 PM Perth.
|
|
_, _ = io.WriteString(w, `{
|
|
"status":"success",
|
|
"data":{"2026-06-11":[{"start":"2026-06-11T00:00:00.000Z"},{"start":"2026-06-11T09:30:00.000Z"}]}
|
|
}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "8:00 AM") {
|
|
t.Errorf("expected UTC slot rendered as '8:00 AM' Perth time, got: %q", body)
|
|
}
|
|
if !strings.Contains(body, "5:30 PM") {
|
|
t.Errorf("expected UTC slot rendered as '5:30 PM' Perth time, got: %q", body)
|
|
}
|
|
if strings.Contains(body, "12:00 AM") {
|
|
t.Errorf("slot rendered as raw UTC wall time ('12:00 AM') instead of Perth time: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_SortsSlotsChronologically guards against Go map iteration
|
|
// order: a Perth-local day can span two UTC date keys, so slots assembled
|
|
// across keys must be explicitly sorted. (2026-06-10T23:00Z == 7:00 AM Perth
|
|
// on June 11; 2026-06-11T01:00:00Z == 9:00 AM Perth on June 11.)
|
|
func TestHandleGetSlots_SortsSlotsChronologically(t *testing.T) {
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{
|
|
"status":"success",
|
|
"data":{
|
|
"2026-06-10":[{"start":"2026-06-10T23:00:00.000Z"}],
|
|
"2026-06-11":[{"start":"2026-06-11T01:00:00.000Z"}]
|
|
}
|
|
}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
body := rec.Body.String()
|
|
early := strings.Index(body, "7:00 AM")
|
|
late := strings.Index(body, "9:00 AM")
|
|
if early == -1 || late == -1 {
|
|
t.Fatalf("expected both '7:00 AM' and '9:00 AM' in body, got: %q", body)
|
|
}
|
|
if early > late {
|
|
t.Errorf("slots out of chronological order: '7:00 AM' rendered after '9:00 AM'")
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_FallsBackToSiteTimezone asserts that when the event
|
|
// timezone can't be fetched (Cal.com schedules endpoint down), the site-wide
|
|
// Localization timezone is used instead of UTC.
|
|
func TestHandleGetSlots_FallsBackToSiteTimezone(t *testing.T) {
|
|
var gotQuery url.Values
|
|
fakeCalcom(t, "" /* schedules endpoint fails */, func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
})
|
|
h, q := newKeyedHandler(t)
|
|
seedSiteTimezone(q, "Australia/Perth")
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&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())
|
|
}
|
|
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
|
|
t.Errorf("outbound timeZone: got %q, want Australia/Perth (site fallback)", got)
|
|
}
|
|
if got := gotQuery.Get("start"); got != "2026-06-11T00:00:00+08:00" {
|
|
t.Errorf("outbound start: got %q, want Perth-midnight window", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_InvalidEventTimezoneFallsBack asserts a garbage timezone
|
|
// on the Cal.com schedule degrades to the site timezone rather than erroring
|
|
// or silently using the bogus value.
|
|
func TestHandleGetSlots_InvalidEventTimezoneFallsBack(t *testing.T) {
|
|
var gotQuery url.Values
|
|
fakeCalcom(t, "Not/AZone", func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
})
|
|
h, q := newKeyedHandler(t)
|
|
seedSiteTimezone(q, "Australia/Perth")
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/slots?username=alice&eventType=30min&date=2026-06-11&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())
|
|
}
|
|
if got := gotQuery.Get("timeZone"); got != "Australia/Perth" {
|
|
t.Errorf("outbound timeZone: got %q, want Australia/Perth (invalid event tz must fall back)", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetForm_DisplayTimeInEventTimezone asserts the form's "selected
|
|
// time" recap renders in the meeting's timezone when the slot start arrives
|
|
// as a UTC timestamp.
|
|
func TestHandleGetForm_DisplayTimeInEventTimezone(t *testing.T) {
|
|
fakeCalcom(t, "Australia/Perth", nil)
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
// 2026-06-11T01:00:00Z == 9:00 AM Australia/Perth.
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/form?blockId=b1&start=2026-06-11T01:00:00Z&date=2026-06-11&username=alice&eventType=30min", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetForm(rec, req)
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "9:00 AM") {
|
|
t.Errorf("expected form recap '9:00 AM' (Perth), got: %q", body)
|
|
}
|
|
if strings.Contains(body, "1:00 AM") {
|
|
t.Errorf("form recap rendered raw UTC wall time ('1:00 AM'): %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleCreateBooking_ConfirmationInEventTimezone asserts the booking
|
|
// confirmation panel renders the meeting time in the meeting's timezone, and
|
|
// that the attendee record sent to Cal.com carries the meeting timezone too —
|
|
// even when a stale cached page still submits a visitor timezone field.
|
|
func TestHandleCreateBooking_ConfirmationInEventTimezone(t *testing.T) {
|
|
var attendeeTZ string
|
|
fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/bookings") {
|
|
var payload struct {
|
|
Attendee struct {
|
|
TimeZone string `json:"timeZone"`
|
|
} `json:"attendee"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
attendeeTZ = payload.Attendee.TimeZone
|
|
_, _ = io.WriteString(w, `{
|
|
"status":"success",
|
|
"data":{"uid":"uid-1","start":"2026-06-11T01:00:00Z","end":"2026-06-11T01:30:00Z","location":"https://meet.example.com/uid-1"}
|
|
}`)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{}}`)
|
|
})
|
|
h, _ := newKeyedHandler(t)
|
|
|
|
form := url.Values{
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"eventType": {"30min"},
|
|
"start": {"2026-06-11T01:00:00Z"},
|
|
"name": {"Sue Findlay"},
|
|
"email": {"sue@example.test"},
|
|
// A page cached before the rework still posts this — must be ignored.
|
|
"timezone": {"America/New_York"},
|
|
}
|
|
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)
|
|
|
|
body := rec.Body.String()
|
|
// 01:00Z on June 11 is 9:00 AM Thursday June 11 in Perth.
|
|
if !strings.Contains(body, "9:00 AM") {
|
|
t.Errorf("expected confirmation in Perth time ('9:00 AM'), got: %q", body)
|
|
}
|
|
if strings.Contains(body, "1:00 AM") {
|
|
t.Errorf("confirmation rendered the UTC instant ('1:00 AM') instead of meeting time: %q", body)
|
|
}
|
|
if attendeeTZ != "Australia/Perth" {
|
|
t.Errorf("attendee timeZone sent to Cal.com: got %q, want Australia/Perth", attendeeTZ)
|
|
}
|
|
}
|
|
|
|
// TestCalcomBookingWidget_NoClientTimezonePlumbing pins the removal of
|
|
// client-side timezone detection — the mechanism behind the original bidbuddy
|
|
// incident (a hidden input's "UTC" default that never updated on cached
|
|
// pages). The widget must carry no timezone input, attribute, or detection
|
|
// call; the locale capture must still defer to DOMContentLoaded (the input it
|
|
// fills sits after the script in parse order); and the visible label must be
|
|
// the server-resolved zone.
|
|
func TestCalcomBookingWidget_NoClientTimezonePlumbing(t *testing.T) {
|
|
var buf strings.Builder
|
|
cfg := BookingConfig{
|
|
BlockID: "b1",
|
|
Username: "alice",
|
|
EventTypeSlug: "30min",
|
|
WeeksToShow: 2,
|
|
WeekStart: "2026-06-11",
|
|
Today: "2026-06-11",
|
|
TimeZone: "Australia/Perth",
|
|
Dates: nil,
|
|
ShowTimezone: true,
|
|
}
|
|
if err := CalcomBookingWidget(cfg).Render(context.Background(), &buf); err != nil {
|
|
t.Fatalf("render widget: %v", err)
|
|
}
|
|
html := buf.String()
|
|
|
|
for _, leftover := range []string{`name="timezone"`, "data-timezone", "Intl.DateTimeFormat"} {
|
|
if strings.Contains(html, leftover) {
|
|
t.Errorf("widget still ships client timezone plumbing (%s)", leftover)
|
|
}
|
|
}
|
|
if !strings.Contains(html, "DOMContentLoaded") {
|
|
t.Errorf("widget init script must defer until DOMContentLoaded — running during parse leaves the locale input at its empty default")
|
|
}
|
|
if !strings.Contains(html, "Australia/Perth") {
|
|
t.Errorf("ShowTimezone label must render the server-resolved zone, got: %q", html)
|
|
}
|
|
}
|