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