930 lines
34 KiB
Go
930 lines
34 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
|
|
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// fakeQuerier is an in-memory implementation of helpers.PluginSettingsQuerier
|
|
// used by tests so SettingsManager can persist and read settings without a DB.
|
|
type fakeQuerier struct {
|
|
mu sync.Mutex
|
|
data map[string][]byte
|
|
}
|
|
|
|
func newFakeQuerier() *fakeQuerier {
|
|
return &fakeQuerier{data: map[string][]byte{}}
|
|
}
|
|
|
|
func (f *fakeQuerier) GetSetting(_ context.Context, key string) (db.Setting, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
v, ok := f.data[key]
|
|
if !ok {
|
|
return db.Setting{}, &pgconn.PgError{Code: "P0002", Message: "no rows"}
|
|
}
|
|
return db.Setting{Key: key, Value: v}, nil
|
|
}
|
|
|
|
func (f *fakeQuerier) UpsertSetting(_ context.Context, arg db.UpsertSettingParams) (db.Setting, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.data[arg.Key] = arg.Value
|
|
return db.Setting(arg), nil
|
|
}
|
|
|
|
// newTestSettings builds a SettingsManager backed by an in-memory querier and
|
|
// a real crypto with a deterministic key — suitable for handler tests.
|
|
func newTestSettings() (*SettingsManager, *fakeQuerier) {
|
|
q := newFakeQuerier()
|
|
c := helpers.NewPluginCrypto("test-secret-key-for-unit-test!!")
|
|
return NewSettingsManager(c, q), q
|
|
}
|
|
|
|
// newTestHandler builds a CalcomHandler wired with an isolated rate limiter
|
|
// — handy for tests that need to drive HandleCreateBooking.
|
|
func newTestHandler() (*CalcomHandler, *fakeQuerier) {
|
|
s, q := newTestSettings()
|
|
return NewCalcomHandler(s, NewRateLimiter(bookingsPerHourPerIP), "https://test.blockninja.dev"), q
|
|
}
|
|
|
|
// TestWriteServerError_DoesNotLeakInternalError verifies that writeServerError
|
|
// returns the public message to the HTTP client and does NOT expose the raw
|
|
// internal error string. This is the canonical behaviour required for the
|
|
// HandleSaveSettings error branch.
|
|
func TestWriteServerError_DoesNotLeakInternalError(t *testing.T) {
|
|
internalErrMsg := "secret connection string dsn=postgres://user:pass@db/internal"
|
|
internalErr := errors.New(internalErrMsg)
|
|
|
|
rec := httptest.NewRecorder()
|
|
writeServerError(rec, internalErr, "Failed to save settings", "calcomblock.HandleSaveSettings")
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d", rec.Code)
|
|
}
|
|
|
|
responseBody := rec.Body.String()
|
|
|
|
if !strings.Contains(responseBody, "Failed to save settings") {
|
|
t.Errorf("expected response to contain \"Failed to save settings\", got: %q", responseBody)
|
|
}
|
|
|
|
if strings.Contains(responseBody, internalErrMsg) {
|
|
t.Errorf("response leaks internal error detail: %q", responseBody)
|
|
}
|
|
|
|
for _, sensitiveFragment := range []string{"postgres://", "user:pass", "dsn=", "internal"} {
|
|
if strings.Contains(responseBody, sensitiveFragment) {
|
|
t.Errorf("response leaks sensitive fragment %q in body: %q", sensitiveFragment, responseBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteInternalServerError_ReturnsGenericMessage verifies that
|
|
// writeInternalServerError returns "Internal Server Error" (not the raw err).
|
|
func TestWriteInternalServerError_ReturnsGenericMessage(t *testing.T) {
|
|
internalErr := errors.New("secret internal detail: file=/etc/secret key=abc123")
|
|
|
|
rec := httptest.NewRecorder()
|
|
writeInternalServerError(rec, internalErr, "calcomblock.SomeHandler")
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d", rec.Code)
|
|
}
|
|
|
|
responseBody := rec.Body.String()
|
|
|
|
if !strings.Contains(responseBody, "Internal Server Error") {
|
|
t.Errorf("expected \"Internal Server Error\" in body, got: %q", responseBody)
|
|
}
|
|
|
|
if strings.Contains(responseBody, "secret internal detail") {
|
|
t.Errorf("response leaks internal error: %q", responseBody)
|
|
}
|
|
}
|
|
|
|
// TestHandleSaveSettings_SuccessPath verifies the success branch works correctly.
|
|
func TestHandleSaveSettings_SuccessPath(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
|
|
body, _ := json.Marshal(map[string]string{"api_key": "cal_live_abc123"})
|
|
req := httptest.NewRequest(http.MethodPost, "/settings", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.HandleSaveSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected status 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("failed to decode response: %v", err)
|
|
}
|
|
if success, _ := resp["success"].(bool); !success {
|
|
t.Errorf("expected success=true in response, got: %v", resp)
|
|
}
|
|
}
|
|
|
|
// TestHandleSaveSettings_InvalidBody verifies that a malformed request body
|
|
// returns 400 Bad Request.
|
|
func TestHandleSaveSettings_InvalidBody(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader("{bad json}"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.HandleSaveSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestSettingsManager_APIKeyPersistsAcrossInstances ensures a new SettingsManager
|
|
// backed by the same querier still sees a previously stored API key — verifying
|
|
// the rewrite genuinely persists rather than holding the value in memory.
|
|
func TestSettingsManager_APIKeyPersistsAcrossInstances(t *testing.T) {
|
|
q := newFakeQuerier()
|
|
c := helpers.NewPluginCrypto("test-secret-key-for-unit-test!!")
|
|
a := NewSettingsManager(c, q)
|
|
|
|
if err := a.SetAPIKey(context.Background(), "cal_live_persisted"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
|
|
// Build a fresh manager over the same querier — simulates server restart.
|
|
b := NewSettingsManager(c, q)
|
|
got, err := b.GetAPIKey(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAPIKey: %v", err)
|
|
}
|
|
if got != "cal_live_persisted" {
|
|
t.Errorf("expected persisted key 'cal_live_persisted', got %q", got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WO-043 — HandleGetSlots error paths (no Cal.com call needed for these paths)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestHandleGetSlots_MissingParams verifies that a GET request missing required
|
|
// params (username, eventType, date) causes HandleGetSlots to render an HTML
|
|
// error rather than attempt any Cal.com request. The apiKey must be set so the
|
|
// handler reaches the param-check logic.
|
|
func TestHandleGetSlots_MissingParams(t *testing.T) {
|
|
h, q := newTestHandler()
|
|
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
_ = q
|
|
|
|
// Missing username, eventType, and date.
|
|
req := httptest.NewRequest(http.MethodGet, "/slots?blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Missing required parameters") {
|
|
t.Errorf("expected 'Missing required parameters' in body, got: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_NoAPIKey verifies that when no API key is configured the
|
|
// handler renders a "not configured" error without making any Cal.com request.
|
|
func TestHandleGetSlots_NoAPIKey(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
// No apiKey stored — leave settings empty.
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/slots?username=alice&eventType=30min&date=2026-05-27&blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "not configured") {
|
|
t.Errorf("expected 'not configured' in body, got: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_InvalidDate verifies that a date param that doesn't match
|
|
// YYYY-MM-DD produces a "Invalid date format" error rendered in HTML.
|
|
func TestHandleGetSlots_InvalidDate(t *testing.T) {
|
|
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=not-a-date&blockId=b1", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSlots(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200 (error rendered in body), got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Invalid date format") {
|
|
t.Errorf("expected 'Invalid date format' in body, got: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSlots_HappyPath — TODO: requires a live-or-mocked Cal.com
|
|
// endpoint because the handler constructs its own client internally via
|
|
// NewCalcomClient(apiKey). Injecting a fake transport at handler level is out
|
|
// of scope for WO-043. The client-level happy-path coverage exists in
|
|
// client_test.go (TestGetSlots_URLAndHeaders, TestGetSlots_ParsesResponse).
|
|
|
|
// TestHandleGetSlots_TimezonePassthrough — TODO: same constraint as HappyPath.
|
|
// Timezone propagation is covered at the client layer in
|
|
// TestGetSlots_URLAndHeaders which verifies the timeZone query param.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WO-044 — Anti-abuse: honeypot, invalid email, rate limit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestHandleCreateBooking_HoneypotShortCircuits verifies that a POST with the
|
|
// honeypot field ("bn_message_extra") populated never reaches Cal.com (hitCount stays 0)
|
|
// and instead returns a fake 200 confirmation to fool the bot.
|
|
func TestHandleCreateBooking_HoneypotShortCircuits(t *testing.T) {
|
|
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-27T10:00:00Z"},
|
|
"name": {"Bob"},
|
|
"email": {"bob@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.Errorf("expected 200 (bot thinks it succeeded), got %d", rec.Code)
|
|
}
|
|
// The honeypot path renders CalcomConfirmation — it contains "Booking Confirmed!"
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Booking Confirmed") {
|
|
t.Errorf("expected honeypot response to contain 'Booking Confirmed', got: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleCreateBooking_InvalidEmail verifies that an email address failing
|
|
// the regex check returns a rendered HTML error with "Invalid email" text.
|
|
// The email check happens BEFORE rate-limit consumption (spec § 3.8), so a
|
|
// flood of bad-email requests cannot drain a victim IP's quota.
|
|
func TestHandleCreateBooking_InvalidEmail(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
if err := h.settings.SetAPIKey(context.Background(), "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
|
|
for _, badEmail := range []string{"", "not-an-email", "@example.com", "x@"} {
|
|
form := url.Values{
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"eventType": {"30min"},
|
|
"start": {"2026-05-27T10:00:00Z"},
|
|
"name": {"Bob"},
|
|
"email": {badEmail},
|
|
}
|
|
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("email=%q: expected 200 with error body, got %d", badEmail, rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
// Empty email triggers "Name and email are required"; others trigger "Invalid email address"
|
|
if !strings.Contains(body, "email") && !strings.Contains(body, "Email") {
|
|
t.Errorf("email=%q: expected email-related error in body, got: %q", badEmail, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validBookingFormRequest builds a POST request whose form passes every cheap
|
|
// validation (name+email present, email regex). The Cal.com call therefore
|
|
// fires, so callers must point calcomBaseURL at a fake server via
|
|
// withCalcomBaseURL.
|
|
func validBookingFormRequest(t *testing.T, ip string) *http.Request {
|
|
t.Helper()
|
|
form := url.Values{
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"eventType": {"30min"},
|
|
"start": {"2026-05-27T10: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")
|
|
if ip != "" {
|
|
req.Header.Set("X-Forwarded-For", ip)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// TestHandleCreateBooking_BadEmailDoesNotConsumeRateLimit guards the spec § 3.8
|
|
// requirement that the email regex runs BEFORE rate-limit consumption. Without
|
|
// this ordering, a single malicious actor could drain an honest user's IP
|
|
// quota by spraying invalid-email requests.
|
|
func TestHandleCreateBooking_BadEmailDoesNotConsumeRateLimit(t *testing.T) {
|
|
// Fake Cal.com — counts booking creations. Garbage-email requests must
|
|
// never reach this server; only the final honest request may. (The honest
|
|
// request also triggers the event-timezone lookup — /schedules — which is
|
|
// not a booking and not counted.)
|
|
var calcomHits atomic.Int32
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/bookings") {
|
|
calcomHits.Add(1)
|
|
}
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"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)
|
|
}
|
|
|
|
const ip = "203.0.113.99"
|
|
|
|
// bookingsPerHourPerIP+1 garbage-email requests — each rejected at the
|
|
// email-regex check, no rate-limit slots consumed, no Cal.com calls.
|
|
for i := range bookingsPerHourPerIP + 1 {
|
|
form := url.Values{
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"eventType": {"30min"},
|
|
"start": {"2026-05-27T10:00:00Z"},
|
|
"name": {"Bot"},
|
|
"email": {"not-an-email"},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/book", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("X-Forwarded-For", ip)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleCreateBooking(rec, req)
|
|
|
|
if rec.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("garbage-email attempt %d was rate-limited — email regex must run before rate-limit per spec § 3.8", i+1)
|
|
}
|
|
}
|
|
if hits := calcomHits.Load(); hits != 0 {
|
|
t.Fatalf("garbage-email phase hit Cal.com %d times — should be 0", hits)
|
|
}
|
|
|
|
// Final honest request — passes regex, consumes first rate-limit slot,
|
|
// reaches Cal.com. Asserts the bucket was untouched by the garbage flood.
|
|
rec := httptest.NewRecorder()
|
|
h.HandleCreateBooking(rec, validBookingFormRequest(t, ip))
|
|
if rec.Code == http.StatusTooManyRequests {
|
|
t.Errorf("honest request was rate-limited because garbage-email requests burned the bucket (spec § 3.8 violation)")
|
|
}
|
|
if hits := calcomHits.Load(); hits != 1 {
|
|
t.Errorf("expected exactly 1 Cal.com hit (the honest request), got %d", hits)
|
|
}
|
|
}
|
|
|
|
// TestHandleCreateBooking_RateLimitAfter10 confirms the per-IP cap: 10 valid
|
|
// requests from the same IP pass, the 11th gets 429. The fake Cal.com is
|
|
// configured to return success so every legitimate request consumes one slot.
|
|
func TestHandleCreateBooking_RateLimitAfter10(t *testing.T) {
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{"uid":"u","rescheduleUrl":"r","cancelUrl":"c"}}`)
|
|
}))
|
|
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 testIP = "203.0.113.42" // TEST-NET-3, safe for tests
|
|
|
|
for i := range bookingsPerHourPerIP {
|
|
rec := httptest.NewRecorder()
|
|
h.HandleCreateBooking(rec, validBookingFormRequest(t, testIP))
|
|
if rec.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("attempt %d was rate-limited too early (expected first %d to be allowed)", i+1, bookingsPerHourPerIP)
|
|
}
|
|
}
|
|
|
|
// 11th request — must be rate-limited.
|
|
rec := httptest.NewRecorder()
|
|
h.HandleCreateBooking(rec, validBookingFormRequest(t, testIP))
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Errorf("11th attempt: expected 429, got %d (body: %q)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WO-045 — routeBookingForm unit tests (field-routing correctness)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestRouteBookingForm_BuiltinExcluded verifies control fields and the attendee
|
|
// phone are kept out of bookingFieldsResponses, the phone routes to
|
|
// attendee.phoneNumber (E.164-normalized), and only genuinely custom keys
|
|
// survive as responses.
|
|
func TestRouteBookingForm_BuiltinExcluded(t *testing.T) {
|
|
form := map[string][]string{
|
|
// Control/built-ins that must be excluded from responses:
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"eventType": {"30min"},
|
|
"start": {"2026-05-27T10:00:00Z"},
|
|
"timezone": {"America/Toronto"},
|
|
"bn_message_extra": {""}, // honeypot (empty)
|
|
"name": {"Bob"},
|
|
"email": {"bob@example.com"},
|
|
"phone": {"+1-555-1234"},
|
|
// Custom fields that MUST survive:
|
|
"q1": {"yes"},
|
|
"custom_field": {"custom_value"},
|
|
}
|
|
|
|
got := routeBookingForm(form, "")
|
|
if got.Responses == nil {
|
|
t.Fatal("expected non-nil responses when custom fields are present")
|
|
}
|
|
for _, builtin := range []string{"blockId", "username", "eventType", "start", "timezone", "bn_message_extra", "name", "email", "phone"} {
|
|
if _, present := got.Responses[builtin]; present {
|
|
t.Errorf("built-in field %q must not appear in bookingFieldsResponses", builtin)
|
|
}
|
|
}
|
|
// Phone routes to the attendee, separators stripped.
|
|
if got.Phone != "+15551234" {
|
|
t.Errorf("expected attendee phone '+15551234', got: %q", got.Phone)
|
|
}
|
|
if got.Responses["q1"] != "yes" {
|
|
t.Errorf("expected q1='yes', got: %v", got.Responses["q1"])
|
|
}
|
|
if got.Responses["custom_field"] != "custom_value" {
|
|
t.Errorf("expected custom_field='custom_value', got: %v", got.Responses["custom_field"])
|
|
}
|
|
}
|
|
|
|
// TestRouteBookingForm_MultiValue verifies that form values with multiple
|
|
// entries (multi-select checkboxes) are preserved as a JSON array rather than
|
|
// collapsed to a single string.
|
|
func TestRouteBookingForm_MultiValue(t *testing.T) {
|
|
form := map[string][]string{
|
|
"topics": {"go", "rust", "zig"}, // multiple selections
|
|
}
|
|
|
|
got := routeBookingForm(form, "")
|
|
if got.Responses == nil {
|
|
t.Fatal("expected non-nil responses")
|
|
}
|
|
vs, ok := got.Responses["topics"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected topics to be []any, got %T: %v", got.Responses["topics"], got.Responses["topics"])
|
|
}
|
|
if len(vs) != 3 || vs[0] != "go" || vs[1] != "rust" || vs[2] != "zig" {
|
|
t.Errorf("multi-value slice wrong: %v", vs)
|
|
}
|
|
}
|
|
|
|
// TestRouteBookingForm_EmptyReturnsNil verifies that a form containing only
|
|
// control fields yields nil responses so the omitempty JSON tag drops the field.
|
|
func TestRouteBookingForm_EmptyReturnsNil(t *testing.T) {
|
|
form := map[string][]string{
|
|
"blockId": {"b1"},
|
|
"username": {"alice"},
|
|
"name": {"Bob"},
|
|
"email": {"bob@example.com"},
|
|
}
|
|
|
|
got := routeBookingForm(form, "")
|
|
if got.Responses != nil {
|
|
t.Errorf("expected nil responses when only control fields present, got: %v", got.Responses)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WO-046 — HandleGetForm default rendering + admin endpoint shapes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestHandleGetForm_NoBookingFieldsRendersDefaults verifies that when no API
|
|
// key is configured (so the Cal.com event-type fetch is skipped), the form
|
|
// falls back to rendering the default name + email fields.
|
|
func TestHandleGetForm_NoBookingFieldsRendersDefaults(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
// No apiKey set — handler skips GetEventType and uses default fields.
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/form?blockId=b1&start=2026-05-27T10:00:00Z&date=2026-05-27&username=alice&eventType=30min&timezone=UTC",
|
|
nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetForm(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `name="name"`) {
|
|
t.Errorf("expected name input in default form, body: %q", body)
|
|
}
|
|
if !strings.Contains(body, `name="email"`) {
|
|
t.Errorf("expected email input in default form, body: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestHandleListEventTypes_NotConfigured verifies the JSON error shape when no
|
|
// API key has been stored. No outbound request should be made.
|
|
func TestHandleListEventTypes_NotConfigured(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
// No apiKey — handler returns the "not configured" JSON response.
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/event-types?username=alice", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleListEventTypes(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if success, _ := got["success"].(bool); success {
|
|
t.Errorf("expected success=false, got: %v", got)
|
|
}
|
|
if errMsg, _ := got["error"].(string); errMsg != "API key not configured" {
|
|
t.Errorf("expected error='API key not configured', got: %q", errMsg)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_ShapeWhenEmpty verifies the JSON shape when no Cal.com
|
|
// settings have been configured: api_key_configured=false, webhook_url present,
|
|
// webhook_secret_configured=false.
|
|
func TestHandleGetSettings_ShapeWhenEmpty(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if v, _ := got["api_key_configured"].(bool); v {
|
|
t.Errorf("expected api_key_configured=false, got true")
|
|
}
|
|
if _, present := got["webhook_url"]; !present {
|
|
t.Errorf("expected webhook_url field in response, got: %v", got)
|
|
}
|
|
if v, _ := got["webhook_secret_configured"].(bool); v {
|
|
t.Errorf("expected webhook_secret_configured=false, got true")
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_ShapeWhenConfigured verifies that after storing an API
|
|
// key and a webhook secret the settings response exposes masked values and sets
|
|
// the boolean flags to true.
|
|
func TestHandleGetSettings_ShapeWhenConfigured(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
ctx := context.Background()
|
|
|
|
if err := h.settings.SetAPIKey(ctx, "cal_live_abc123456789"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
if err := h.settings.SetWebhookSecret(ctx, "deadbeefdeadbeefdeadbeefdeadbeef00000000000000000000000000000000"); err != nil {
|
|
t.Fatalf("SetWebhookSecret: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d (body: %q)", rec.Code, rec.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if v, _ := got["api_key_configured"].(bool); !v {
|
|
t.Errorf("expected api_key_configured=true, got false; resp=%v", got)
|
|
}
|
|
if _, present := got["api_key_masked"]; !present {
|
|
t.Errorf("expected api_key_masked field in response")
|
|
}
|
|
if v, _ := got["webhook_secret_configured"].(bool); !v {
|
|
t.Errorf("expected webhook_secret_configured=true, got false; resp=%v", got)
|
|
}
|
|
if _, present := got["webhook_secret_masked"]; !present {
|
|
t.Errorf("expected webhook_secret_masked field in response")
|
|
}
|
|
}
|
|
|
|
// TestHandleSaveSettings_PersistsUsernameFromMe asserts that a successful API
|
|
// key save fires Cal.com /v2/me and persists the resolved username so the
|
|
// block editor doesn't have to ask the admin to type it.
|
|
func TestHandleSaveSettings_PersistsUsernameFromMe(t *testing.T) {
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/me" {
|
|
t.Errorf("unexpected upstream path %q (want /me)", r.URL.Path)
|
|
}
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
withCalcomBaseURL(t, fake.URL)
|
|
|
|
h, _ := newTestHandler()
|
|
body := bytes.NewBufferString(`{"api_key":"cal_live_test"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/settings", body)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleSaveSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, err := h.settings.GetUsername(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetUsername: %v", err)
|
|
}
|
|
if got != "alice" {
|
|
t.Errorf("expected username alice, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleSaveSettings_MeFailureDoesNotBlockSave guarantees that a transient
|
|
// Cal.com /me failure does NOT cause the API key save to fail. The key is
|
|
// still persisted; username is left empty for the admin to refresh later.
|
|
func TestHandleSaveSettings_MeFailureDoesNotBlockSave(t *testing.T) {
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
withCalcomBaseURL(t, fake.URL)
|
|
|
|
h, _ := newTestHandler()
|
|
body := bytes.NewBufferString(`{"api_key":"cal_live_test"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/settings", body)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleSaveSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 even when /me is down, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
gotKey, err := h.settings.GetAPIKey(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAPIKey: %v", err)
|
|
}
|
|
if gotKey != "cal_live_test" {
|
|
t.Errorf("API key should still be saved, got %q", gotKey)
|
|
}
|
|
gotUser, _ := h.settings.GetUsername(context.Background())
|
|
if gotUser != "" {
|
|
t.Errorf("username should be empty when /me failed, got %q", gotUser)
|
|
}
|
|
}
|
|
|
|
// TestHandleSaveSettings_ClearingKeyClearsUsername confirms that posting an
|
|
// empty api_key both clears the key and the cached username so the next save
|
|
// re-derives identity from the fresh key.
|
|
func TestHandleSaveSettings_ClearingKeyClearsUsername(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
ctx := context.Background()
|
|
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
if err := h.settings.SetUsername(ctx, "alice"); err != nil {
|
|
t.Fatalf("SetUsername: %v", err)
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"api_key":""}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/settings", body)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleSaveSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
got, _ := h.settings.GetUsername(ctx)
|
|
if got != "" {
|
|
t.Errorf("username should be cleared on key removal, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_IncludesUsername confirms the JSON response surfaces
|
|
// the cached username so the block editor can stamp it into content without
|
|
// asking the admin to type it.
|
|
func TestHandleGetSettings_IncludesUsername(t *testing.T) {
|
|
h, _ := newTestHandler()
|
|
ctx := context.Background()
|
|
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
if err := h.settings.SetUsername(ctx, "alice"); err != nil {
|
|
t.Fatalf("SetUsername: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got["username"] != "alice" {
|
|
t.Errorf("expected username=alice in response, got %v", got["username"])
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_BackfillsUsernameWhenMissing covers the upgrade path for
|
|
// tenants whose API key was saved BEFORE the auto-username feature shipped: the
|
|
// key is present, username is empty, so HandleGetSettings lazily fires /v2/me
|
|
// and persists the result. Without this self-heal step those tenants would have
|
|
// to manually clear and re-save the key.
|
|
func TestHandleGetSettings_BackfillsUsernameWhenMissing(t *testing.T) {
|
|
var meHits atomic.Int32
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/me" {
|
|
t.Errorf("unexpected upstream path %q (want /me)", r.URL.Path)
|
|
}
|
|
meHits.Add(1)
|
|
_, _ = io.WriteString(w, `{"status":"success","data":{"username":"alice","email":"alice@example.com"}}`)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
withCalcomBaseURL(t, fake.URL)
|
|
|
|
h, _ := newTestHandler()
|
|
ctx := context.Background()
|
|
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
// Deliberately leave username unset to simulate a pre-feature save.
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got["username"] != "alice" {
|
|
t.Errorf("expected username=alice in response, got %v", got["username"])
|
|
}
|
|
persisted, _ := h.settings.GetUsername(ctx)
|
|
if persisted != "alice" {
|
|
t.Errorf("expected backfill to persist username, got %q", persisted)
|
|
}
|
|
|
|
// A second GET must NOT re-fire /me — the cache is now warm.
|
|
rec2 := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec2, req)
|
|
if hits := meHits.Load(); hits != 1 {
|
|
t.Errorf("expected exactly 1 /me hit after both GETs, got %d", hits)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_NoBackfillWhenNoAPIKey guards against the backfill
|
|
// firing /me with an empty key — that would 401 every GET on an unconfigured
|
|
// plugin.
|
|
func TestHandleGetSettings_NoBackfillWhenNoAPIKey(t *testing.T) {
|
|
var meHits atomic.Int32
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
meHits.Add(1)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
withCalcomBaseURL(t, fake.URL)
|
|
|
|
h, _ := newTestHandler()
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if hits := meHits.Load(); hits != 0 {
|
|
t.Errorf("expected zero /me hits without an API key, got %d", hits)
|
|
}
|
|
}
|
|
|
|
// TestHandleGetSettings_BackfillFailureIsNonFatal asserts that a /me error
|
|
// during backfill never prevents settings from rendering — the response is
|
|
// still 200 with username left empty.
|
|
func TestHandleGetSettings_BackfillFailureIsNonFatal(t *testing.T) {
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
t.Cleanup(fake.Close)
|
|
withCalcomBaseURL(t, fake.URL)
|
|
|
|
h, _ := newTestHandler()
|
|
ctx := context.Background()
|
|
if err := h.settings.SetAPIKey(ctx, "cal_live_test"); err != nil {
|
|
t.Fatalf("SetAPIKey: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.HandleGetSettings(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got["username"] != "" {
|
|
t.Errorf("expected empty username on /me failure, got %v", got["username"])
|
|
}
|
|
}
|
|
|
|
// TestSettingsManager_UsernamePersists round-trips username through the in-memory
|
|
// settings store: empty by default, persisted via SetUsername, read back via
|
|
// GetUsername. Username is NOT a secret so it is stored as plain text.
|
|
func TestSettingsManager_UsernamePersists(t *testing.T) {
|
|
s, _ := newTestSettings()
|
|
ctx := context.Background()
|
|
|
|
got, err := s.GetUsername(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetUsername on empty store: %v", err)
|
|
}
|
|
if got != "" {
|
|
t.Errorf("expected empty username, got %q", got)
|
|
}
|
|
|
|
if err := s.SetUsername(ctx, "alice"); err != nil {
|
|
t.Fatalf("SetUsername: %v", err)
|
|
}
|
|
got, err = s.GetUsername(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetUsername after set: %v", err)
|
|
}
|
|
if got != "alice" {
|
|
t.Errorf("Username round-trip: got %q, want alice", got)
|
|
}
|
|
}
|
|
|
|
// TestSettingsManager_SetUsernameEmptyClears confirms that passing "" deletes
|
|
// the persisted username so the admin can effectively "forget" the cached
|
|
// owner on demand (e.g. after rotating the API key).
|
|
func TestSettingsManager_SetUsernameEmptyClears(t *testing.T) {
|
|
s, _ := newTestSettings()
|
|
ctx := context.Background()
|
|
if err := s.SetUsername(ctx, "alice"); err != nil {
|
|
t.Fatalf("SetUsername: %v", err)
|
|
}
|
|
if err := s.SetUsername(ctx, ""); err != nil {
|
|
t.Fatalf("SetUsername empty: %v", err)
|
|
}
|
|
got, err := s.GetUsername(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetUsername: %v", err)
|
|
}
|
|
if got != "" {
|
|
t.Errorf("expected cleared username, got %q", got)
|
|
}
|
|
}
|