calcomblock/timezone_test.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
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>
2026-07-05 03:04:18 +08:00

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)
}
}