calcomblock/handler_test.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)
}
}