From fcce7f20ef48896d0d749afe635d746fc7a97b76 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Sat, 23 May 2026 10:55:53 -0400 Subject: [PATCH 1/2] Validate BMLT root server on save, reject invalid/unreachable hostnames [#282] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BMLT root server URL was previously saved with only sanitize_text_field(), so a typo, a non-URL string, an http:// server, or a URL that isn't a BMLT root server was persisted silently and only surfaced later as broken/empty event lookups. Add RootServerValidator with layered checks (format -> https scheme -> live GetServerInfo handshake with a bounded 10s timeout). Wire it into the settings save path so an invalid or unreachable root server is rejected with a clear error before anything is persisted, preserving the previously stored value. Validation runs only when the value actually changes, so saving unrelated settings never triggers a network call. Also add an admin-only /validate-root-server REST endpoint and a "Test connection" button in the Settings UI to verify without saving, and surface REST error messages in the apiFetch wrapper. Covered by new unit tests for the validator (valid/reachable, malformed, non-https, unreachable, non-200, reachable-but-not-BMLT) and the save path (reject + previous-value-preserved) and the new endpoint. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 202F8173-BB91-4E6F-B986-1CE19C339F1A --- assets/js/src/components/admin/Settings.js | 36 ++++ assets/js/src/util.js | 13 +- includes/Rest/Helpers/RootServerValidator.php | 141 ++++++++++++++ includes/Rest/SettingsController.php | 60 +++++- languages/mayo-events-manager.pot | 177 +++++++++++------- readme.txt | 1 + .../Rest/Helpers/RootServerValidatorTest.php | 103 ++++++++++ tests/Unit/Rest/SettingsControllerTest.php | 111 +++++++++++ tests/Unit/TestCase.php | 18 ++ 9 files changed, 591 insertions(+), 69 deletions(-) create mode 100644 includes/Rest/Helpers/RootServerValidator.php create mode 100644 tests/Unit/Rest/Helpers/RootServerValidatorTest.php diff --git a/assets/js/src/components/admin/Settings.js b/assets/js/src/components/admin/Settings.js index 91c20c8..883e561 100644 --- a/assets/js/src/components/admin/Settings.js +++ b/assets/js/src/components/admin/Settings.js @@ -78,6 +78,7 @@ const Settings = () => { }); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [externalSources, setExternalSources] = useState([]); @@ -338,6 +339,30 @@ const Settings = () => { } }; + const handleTestConnection = async () => { + try { + setIsTesting(true); + setError(null); + setSuccessMessage(null); + + if (!isValidHttpsUrl(settings.bmlt_root_server)) { + throw new Error(__('BMLT Root Server URL must use HTTPS protocol.', 'mayo-events-manager')); + } + + await apiFetch('/validate-root-server', { + method: 'POST', + body: JSON.stringify({ bmlt_root_server: settings.bmlt_root_server }) + }); + + setSuccessMessage(__('Successfully connected to the BMLT root server.', 'mayo-events-manager')); + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + setError(err.message || __('Could not reach the BMLT root server.', 'mayo-events-manager')); + } finally { + setIsTesting(false); + } + }; + if (isLoading) { return (
@@ -399,6 +424,17 @@ const Settings = () => { /> + + + + { const response = await fetch(url, fetchOptions); if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + // Prefer the REST API error message (e.g. WP_Error) when present + // so callers can show a meaningful reason instead of a bare status. + let message = `API error: ${response.status} ${response.statusText}`; + try { + const errorBody = await response.json(); + if (errorBody && errorBody.message) { + message = errorBody.message; + } + } catch (e) { + // Response body wasn't JSON; keep the status-based message. + } + throw new Error(message); } return await response.json(); diff --git a/includes/Rest/Helpers/RootServerValidator.php b/includes/Rest/Helpers/RootServerValidator.php new file mode 100644 index 0000000..9d72ce2 --- /dev/null +++ b/includes/Rest/Helpers/RootServerValidator.php @@ -0,0 +1,141 @@ + 400 ] + ); + } + + // 1. Format — must be a valid absolute URL. + $normalized = untrailingslashit( esc_url_raw( $url ) ); + + if ( empty( $normalized ) || ! wp_http_validate_url( $normalized ) ) { + return new \WP_Error( + 'invalid_root_server_url', + sprintf( + /* translators: %s: the URL the user entered. */ + __( '"%s" is not a valid URL.', 'mayo-events-manager' ), + $url + ), + [ 'status' => 400 ] + ); + } + + // 2. Scheme — require https. + $scheme = strtolower( (string) wp_parse_url( $normalized, PHP_URL_SCHEME ) ); + if ( $scheme !== 'https' ) { + return new \WP_Error( + 'invalid_root_server_url', + sprintf( + /* translators: %s: the URL the user entered. */ + __( 'The BMLT root server URL must start with "https://" (got "%s").', 'mayo-events-manager' ), + $url + ), + [ 'status' => 400 ] + ); + } + + // 3. Reachability — handshake against the BMLT semantic endpoint. + $response = wp_remote_get( + $normalized . '/client_interface/json/?switcher=GetServerInfo', + [ + 'timeout' => self::TIMEOUT, + 'sslverify' => true, + ] + ); + + if ( is_wp_error( $response ) ) { + return new \WP_Error( + 'root_server_unreachable', + sprintf( + /* translators: %s: the BMLT root server URL. */ + __( 'Could not reach a BMLT root server at %s', 'mayo-events-manager' ), + $normalized + ), + [ 'status' => 400 ] + ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + return new \WP_Error( + 'root_server_unreachable', + sprintf( + /* translators: 1: the BMLT root server URL, 2: the HTTP status code returned. */ + __( 'Could not reach a BMLT root server at %1$s (HTTP %2$d).', 'mayo-events-manager' ), + $normalized, + $code + ), + [ 'status' => 400 ] + ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! self::looks_like_server_info( $data ) ) { + return new \WP_Error( + 'not_a_bmlt_root_server', + sprintf( + /* translators: %s: the BMLT root server URL. */ + __( 'The URL %s did not respond like a BMLT root server.', 'mayo-events-manager' ), + $normalized + ), + [ 'status' => 400 ] + ); + } + + return $normalized; + } + + /** + * Determine whether a decoded GetServerInfo response looks like a BMLT + * root server. GetServerInfo returns a single-element list of objects + * carrying a `version` field, e.g. [ { "version": "3.0.0", ... } ]. + * + * @param mixed $data Decoded JSON response body. + * @return bool + */ + private static function looks_like_server_info( $data ) { + if ( ! is_array( $data ) ) { + return false; + } + + if ( isset( $data['version'] ) ) { + return true; + } + + return isset( $data[0] ) && is_array( $data[0] ) && isset( $data[0]['version'] ); + } +} diff --git a/includes/Rest/SettingsController.php b/includes/Rest/SettingsController.php index d52eed8..63d04ec 100644 --- a/includes/Rest/SettingsController.php +++ b/includes/Rest/SettingsController.php @@ -2,6 +2,8 @@ namespace BmltEnabled\Mayo\Rest; +use BmltEnabled\Mayo\Rest\Helpers\RootServerValidator; + if ( ! defined( 'ABSPATH' ) ) exit; /** @@ -26,6 +28,14 @@ public static function register_routes() { return current_user_can('manage_options'); } ]); + + register_rest_route('event-manager/v1', '/validate-root-server', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'validate_root_server'], + 'permission_callback' => function() { + return current_user_can('manage_options'); + } + ]); } /** @@ -78,9 +88,25 @@ public static function update_settings($request) { $params = $request->get_params(); $settings = get_option('mayo_settings', []); - // Update BMLT root server + // Update BMLT root server. Validate only when the value actually + // changes so saving unrelated settings (or re-saving an already-valid + // root that is momentarily unreachable) never triggers a network call + // or blocks the save. The bad value is never persisted: an invalid + // root server returns early before any update_option(), preserving the + // previously stored value. if (isset($params['bmlt_root_server'])) { - $settings['bmlt_root_server'] = sanitize_text_field($params['bmlt_root_server']); + $new_root = sanitize_text_field($params['bmlt_root_server']); + $current_root = $settings['bmlt_root_server'] ?? ''; + + if ($new_root === '') { + $settings['bmlt_root_server'] = ''; + } elseif ($new_root !== $current_root) { + $validated = RootServerValidator::validate($new_root); + if (is_wp_error($validated)) { + return $validated; + } + $settings['bmlt_root_server'] = $validated; + } } // Update notification email @@ -156,6 +182,36 @@ public static function update_settings($request) { ]); } + /** + * Validate a BMLT root server URL without saving it. + * + * Backs the "Test connection" affordance in the admin Settings UI. + * + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error + */ + public static function validate_root_server($request) { + if (!current_user_can('manage_options')) { + return new \WP_Error( + 'rest_forbidden', + __('Sorry, you are not allowed to validate settings.', 'mayo-events-manager'), + ['status' => 401] + ); + } + + $url = sanitize_text_field($request->get_param('bmlt_root_server') ?? ''); + $result = RootServerValidator::validate($url); + + if (is_wp_error($result)) { + return $result; + } + + return new \WP_REST_Response([ + 'success' => true, + 'bmlt_root_server' => $result, + ]); + } + /** * Generate a readable ID for external sources * diff --git a/languages/mayo-events-manager.pot b/languages/mayo-events-manager.pot index dad0d7f..cb40fa8 100644 --- a/languages/mayo-events-manager.pot +++ b/languages/mayo-events-manager.pot @@ -4,13 +4,17 @@ msgid "" msgstr "" "Project-Id-Version: Mayo Events Manager\n" "Report-Msgid-Bugs-To: https://github.com/bmlt-enabled/mayo/issues\n" -"POT-Creation-Date: 2026-05-20 03:28+0000\n" +"POT-Creation-Date: 2026-05-23 14:55+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language-Team: \n" "X-Domain: mayo-events-manager\n" +#: includes/Rest/Helpers/RootServerValidator.php:50 +msgid "\"%s\" is not a valid URL." +msgstr "" + #: includes/Admin.php:365 msgid "%d skipped" msgid_plural "%d skipped" @@ -79,7 +83,7 @@ msgid "Active" msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:183 -#: assets/js/src/components/admin/Settings.js:526 +#: assets/js/src/components/admin/Settings.js:562 #: assets/js/src/components/public/EventForm.js:524 msgid "Activity" msgstr "" @@ -103,7 +107,7 @@ msgstr "" msgid "Add New Event" msgstr "" -#: assets/js/src/components/admin/Settings.js:501 +#: assets/js/src/components/admin/Settings.js:537 msgid "Add New External Source" msgstr "" @@ -133,11 +137,11 @@ msgid "Address:" msgstr "" #: assets/js/src/components/admin/Subscribers.js:337 -#: assets/js/src/components/admin/Settings.js:468 +#: assets/js/src/components/admin/Settings.js:504 msgid "All" msgstr "" -#: assets/js/src/components/admin/Settings.js:525 +#: assets/js/src/components/admin/Settings.js:561 msgid "All Event Types" msgstr "" @@ -227,7 +231,7 @@ msgstr "" msgid "Are you sure you want to copy this event?" msgstr "" -#: assets/js/src/components/admin/Settings.js:255 +#: assets/js/src/components/admin/Settings.js:256 msgid "Are you sure you want to delete this external source?" msgstr "" @@ -251,19 +255,20 @@ msgstr "" msgid "August" msgstr "" -#: assets/js/src/components/admin/Settings.js:650 +#: assets/js/src/components/admin/Settings.js:686 msgid "Auto-include: Automatically add to existing subscribers" msgstr "" -#: assets/js/src/components/admin/Settings.js:385 +#: assets/js/src/components/admin/Settings.js:410 msgid "BMLT Root Server URL" msgstr "" -#: assets/js/src/components/admin/Settings.js:310 +#: assets/js/src/components/admin/Settings.js:311 +#: assets/js/src/components/admin/Settings.js:349 msgid "BMLT Root Server URL must use HTTPS protocol." msgstr "" -#: assets/js/src/components/admin/Settings.js:382 +#: assets/js/src/components/admin/Settings.js:407 msgid "BMLT Settings" msgstr "" @@ -299,13 +304,13 @@ msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:414 #: assets/js/src/components/admin/AnnouncementEditor.js:463 #: assets/js/src/components/admin/Subscribers.js:129 -#: assets/js/src/components/admin/Settings.js:578 +#: assets/js/src/components/admin/Settings.js:614 #: mayo-events-manager.php:791 msgid "Cancel" msgstr "" #: assets/js/src/components/admin/Subscribers.js:80 -#: assets/js/src/components/admin/Settings.js:544 +#: assets/js/src/components/admin/Settings.js:580 #: assets/js/src/components/public/EventForm.js:913 #: assets/js/src/components/public/AnnouncementForm.js:634 #: assets/js/src/components/public/SubscribeForm.js:162 @@ -319,7 +324,7 @@ msgstr "" msgid "Categories (comma-separated slugs):" msgstr "" -#: assets/js/src/components/admin/Settings.js:594 +#: assets/js/src/components/admin/Settings.js:630 msgid "Categories available for subscription:" msgstr "" @@ -336,7 +341,7 @@ msgid "Category" msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:184 -#: assets/js/src/components/admin/Settings.js:528 +#: assets/js/src/components/admin/Settings.js:564 #: assets/js/src/components/public/EventForm.js:525 msgid "Celebration" msgstr "" @@ -374,11 +379,11 @@ msgstr "" msgid "Collapse All" msgstr "" -#: assets/js/src/components/admin/Settings.js:428 +#: assets/js/src/components/admin/Settings.js:464 msgid "Comma-separated list of service body IDs (e.g., 1,2,3). When specified, only these service bodies will be available for event submission. Leave empty to allow all service bodies." msgstr "" -#: assets/js/src/components/admin/Settings.js:589 +#: assets/js/src/components/admin/Settings.js:625 msgid "Configure which categories, tags, and service bodies are available for subscribers to choose from when signing up for announcement notifications." msgstr "" @@ -429,11 +434,11 @@ msgstr "" msgid "Copy" msgstr "" -#: assets/js/src/components/admin/Settings.js:462 +#: assets/js/src/components/admin/Settings.js:498 msgid "Copy ID" msgstr "" -#: assets/js/src/components/admin/Settings.js:464 +#: assets/js/src/components/admin/Settings.js:500 msgid "Copy ID to clipboard" msgstr "" @@ -442,6 +447,18 @@ msgstr "" msgid "Copy to Clipboard" msgstr "" +#: includes/Rest/Helpers/RootServerValidator.php:98 +msgid "Could not reach a BMLT root server at %1$s (HTTP %2$d)." +msgstr "" + +#: includes/Rest/Helpers/RootServerValidator.php:85 +msgid "Could not reach a BMLT root server at %s" +msgstr "" + +#: assets/js/src/components/admin/Settings.js:360 +msgid "Could not reach the BMLT root server." +msgstr "" + #: assets/js/src/components/admin/EventBlockEditorSidebar.js:600 msgid "Create Announcement" msgstr "" @@ -505,7 +522,7 @@ msgid "Default" msgstr "" #: assets/js/src/components/admin/Subscribers.js:424 -#: assets/js/src/components/admin/Settings.js:487 +#: assets/js/src/components/admin/Settings.js:523 msgid "Delete" msgstr "" @@ -530,7 +547,7 @@ msgstr "" msgid "Details:" msgstr "" -#: assets/js/src/components/admin/Settings.js:471 +#: assets/js/src/components/admin/Settings.js:507 msgid "Disabled" msgstr "" @@ -579,7 +596,7 @@ msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:584 #: assets/js/src/components/admin/AnnouncementEditor.js:1115 #: assets/js/src/components/admin/Subscribers.js:414 -#: assets/js/src/components/admin/Settings.js:481 +#: assets/js/src/components/admin/Settings.js:517 msgid "Edit" msgstr "" @@ -608,7 +625,7 @@ msgstr "" msgid "Email address" msgstr "" -#: assets/js/src/components/admin/Settings.js:410 +#: assets/js/src/components/admin/Settings.js:446 msgid "Email addresses to receive event submission notifications. Multiple emails can be separated by commas or semicolons. Leave empty to send to all admins." msgstr "" @@ -617,11 +634,11 @@ msgstr "" msgid "Email: %s" msgstr "" -#: assets/js/src/components/admin/Settings.js:558 +#: assets/js/src/components/admin/Settings.js:594 msgid "Enable Source" msgstr "" -#: assets/js/src/components/admin/Settings.js:471 +#: assets/js/src/components/admin/Settings.js:507 msgid "Enabled" msgstr "" @@ -665,15 +682,15 @@ msgstr "" msgid "End:" msgstr "" -#: assets/js/src/components/admin/Settings.js:518 +#: assets/js/src/components/admin/Settings.js:554 msgid "Enter a friendly name for this source (e.g., District 5 Website)" msgstr "" -#: assets/js/src/components/admin/Settings.js:511 +#: assets/js/src/components/admin/Settings.js:547 msgid "Enter the URL of the WordPress site (e.g., https://example.com)" msgstr "" -#: assets/js/src/components/admin/Settings.js:391 +#: assets/js/src/components/admin/Settings.js:416 msgid "Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)" msgstr "" @@ -736,7 +753,7 @@ msgid "Event Submission Form Shortcode" msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:178 -#: assets/js/src/components/admin/Settings.js:522 +#: assets/js/src/components/admin/Settings.js:558 #: assets/js/src/components/public/EventForm.js:513 #: assets/js/src/components/public/cards/EventCard.js:159 #: assets/js/src/components/public/EventFilters.js:8 @@ -798,7 +815,7 @@ msgstr "" msgid "External" msgstr "" -#: assets/js/src/components/admin/Settings.js:450 +#: assets/js/src/components/admin/Settings.js:486 msgid "External Event Sources" msgstr "" @@ -806,15 +823,15 @@ msgstr "" msgid "External Link" msgstr "" -#: assets/js/src/components/admin/Settings.js:203 +#: assets/js/src/components/admin/Settings.js:204 msgid "External source URL must use HTTPS protocol." msgstr "" -#: assets/js/src/components/admin/Settings.js:273 +#: assets/js/src/components/admin/Settings.js:274 msgid "External source deleted successfully!" msgstr "" -#: assets/js/src/components/admin/Settings.js:232 +#: assets/js/src/components/admin/Settings.js:233 msgid "External source saved successfully!" msgstr "" @@ -826,7 +843,7 @@ msgstr "" msgid "Failed to create copy" msgstr "" -#: assets/js/src/components/admin/Settings.js:276 +#: assets/js/src/components/admin/Settings.js:277 msgid "Failed to delete external source." msgstr "" @@ -842,7 +859,7 @@ msgstr "" msgid "Failed to load events" msgstr "" -#: assets/js/src/components/admin/Settings.js:161 +#: assets/js/src/components/admin/Settings.js:162 msgid "Failed to load settings. Please refresh the page and try again." msgstr "" @@ -850,11 +867,11 @@ msgstr "" msgid "Failed to load subscribers" msgstr "" -#: assets/js/src/components/admin/Settings.js:235 +#: assets/js/src/components/admin/Settings.js:236 msgid "Failed to save external source." msgstr "" -#: assets/js/src/components/admin/Settings.js:335 +#: assets/js/src/components/admin/Settings.js:336 msgid "Failed to save settings." msgstr "" @@ -883,15 +900,15 @@ msgstr "" msgid "Fifth" msgstr "" -#: assets/js/src/components/admin/Settings.js:547 +#: assets/js/src/components/admin/Settings.js:583 msgid "Filter by categories (comma-separated)" msgstr "" -#: assets/js/src/components/admin/Settings.js:540 +#: assets/js/src/components/admin/Settings.js:576 msgid "Filter by service body (optional)" msgstr "" -#: assets/js/src/components/admin/Settings.js:554 +#: assets/js/src/components/admin/Settings.js:590 msgid "Filter by tags (comma-separated)" msgstr "" @@ -1099,7 +1116,7 @@ msgstr "" msgid "Loading more..." msgstr "" -#: assets/js/src/components/admin/Settings.js:344 +#: assets/js/src/components/admin/Settings.js:369 msgid "Loading settings..." msgstr "" @@ -1182,7 +1199,7 @@ msgstr "" msgid "Mayo Event Announcements" msgstr "" -#: assets/js/src/components/admin/Settings.js:351 +#: assets/js/src/components/admin/Settings.js:376 msgid "Mayo Events Manager Settings" msgstr "" @@ -1318,7 +1335,7 @@ msgstr "" msgid "Note: If you don't see our emails, please check your spam or junk folder." msgstr "" -#: assets/js/src/components/admin/Settings.js:404 +#: assets/js/src/components/admin/Settings.js:440 msgid "Notification Email" msgstr "" @@ -1365,7 +1382,7 @@ msgstr "" msgid "Open in your default calendar app (Apple Calendar, Outlook, etc.)" msgstr "" -#: assets/js/src/components/admin/Settings.js:651 +#: assets/js/src/components/admin/Settings.js:687 msgid "Opt-in: Existing subscribers must manually add new options" msgstr "" @@ -1394,6 +1411,10 @@ msgstr "" msgid "Please confirm your subscription to %s announcements" msgstr "" +#: includes/Rest/Helpers/RootServerValidator.php:37 +msgid "Please enter a BMLT root server URL." +msgstr "" + #: assets/js/src/components/admin/AnnouncementEditor.js:358 msgid "Please enter a valid URL (e.g., https://example.com)" msgstr "" @@ -1402,11 +1423,11 @@ msgstr "" msgid "Please enter a valid email address." msgstr "" -#: assets/js/src/components/admin/Settings.js:315 +#: assets/js/src/components/admin/Settings.js:316 msgid "Please enter valid email addresses for notifications. Multiple emails can be separated by commas or semicolons." msgstr "" -#: assets/js/src/components/admin/Settings.js:409 +#: assets/js/src/components/admin/Settings.js:445 msgid "Please enter valid email addresses. Multiple emails can be separated by commas or semicolons." msgstr "" @@ -1530,7 +1551,7 @@ msgstr "" msgid "Repeat every" msgstr "" -#: assets/js/src/components/admin/Settings.js:425 +#: assets/js/src/components/admin/Settings.js:461 msgid "Restricted Service Bodies" msgstr "" @@ -1561,14 +1582,14 @@ msgstr "" msgid "Save Preferences" msgstr "" -#: assets/js/src/components/admin/Settings.js:443 -#: assets/js/src/components/admin/Settings.js:666 +#: assets/js/src/components/admin/Settings.js:479 +#: assets/js/src/components/admin/Settings.js:702 msgid "Save Settings" msgstr "" #: assets/js/src/components/admin/Subscribers.js:132 -#: assets/js/src/components/admin/Settings.js:443 -#: assets/js/src/components/admin/Settings.js:666 +#: assets/js/src/components/admin/Settings.js:479 +#: assets/js/src/components/admin/Settings.js:702 msgid "Saving..." msgstr "" @@ -1619,7 +1640,7 @@ msgstr "" msgid "Select a service body" msgstr "" -#: assets/js/src/components/admin/Settings.js:533 +#: assets/js/src/components/admin/Settings.js:569 msgid "Select the event type" msgstr "" @@ -1646,7 +1667,7 @@ msgid "September" msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:182 -#: assets/js/src/components/admin/Settings.js:527 +#: assets/js/src/components/admin/Settings.js:563 #: assets/js/src/components/public/EventForm.js:523 msgid "Service" msgstr "" @@ -1657,7 +1678,7 @@ msgstr "" msgid "Service Bodies" msgstr "" -#: assets/js/src/components/admin/Settings.js:626 +#: assets/js/src/components/admin/Settings.js:662 msgid "Service Bodies available for subscription:" msgstr "" @@ -1667,7 +1688,7 @@ msgstr "" #: assets/js/src/components/admin/EventBlockEditorSidebar.js:432 #: assets/js/src/components/admin/AnnouncementEditor.js:955 #: assets/js/src/components/admin/AnnouncementEditor.js:957 -#: assets/js/src/components/admin/Settings.js:537 +#: assets/js/src/components/admin/Settings.js:573 #: assets/js/src/components/public/EventForm.js:531 #: assets/js/src/components/public/cards/EventCard.js:212 #: assets/js/src/components/public/AnnouncementForm.js:451 @@ -1682,7 +1703,7 @@ msgstr "" msgid "Service Body (%s)" msgstr "" -#: assets/js/src/components/admin/Settings.js:422 +#: assets/js/src/components/admin/Settings.js:458 msgid "Service Body Configuration" msgstr "" @@ -1690,7 +1711,7 @@ msgstr "" msgid "Service Body ID: %s" msgstr "" -#: assets/js/src/components/admin/Settings.js:469 +#: assets/js/src/components/admin/Settings.js:505 #: assets/js/src/components/public/EventArchive.js:127 msgid "Service Body:" msgstr "" @@ -1703,7 +1724,7 @@ msgstr "" msgid "Settings" msgstr "" -#: assets/js/src/components/admin/Settings.js:332 +#: assets/js/src/components/admin/Settings.js:333 msgid "Settings saved successfully!" msgstr "" @@ -1721,7 +1742,7 @@ msgstr "" msgid "Show Shortcode" msgstr "" -#: assets/js/src/components/admin/Settings.js:508 +#: assets/js/src/components/admin/Settings.js:544 msgid "Site URL" msgstr "" @@ -1733,11 +1754,15 @@ msgstr "" msgid "Skipped Occurrences" msgstr "" +#: includes/Rest/SettingsController.php:197 +msgid "Sorry, you are not allowed to validate settings." +msgstr "" + #: includes/Widgets/AnnouncementWidget.php:162 msgid "Sort By:" msgstr "" -#: assets/js/src/components/admin/Settings.js:515 +#: assets/js/src/components/admin/Settings.js:551 msgid "Source Name" msgstr "" @@ -1839,7 +1864,7 @@ msgid "Subscription Confirmed" msgstr "" #: assets/js/src/components/admin/Subscribers.js:73 -#: assets/js/src/components/admin/Settings.js:587 +#: assets/js/src/components/admin/Settings.js:623 msgid "Subscription Preferences" msgstr "" @@ -1847,6 +1872,10 @@ msgstr "" msgid "Subscription not found." msgstr "" +#: assets/js/src/components/admin/Settings.js:357 +msgid "Successfully connected to the BMLT root server." +msgstr "" + #: assets/js/src/components/public/CalendarView.js:80 msgid "Sun" msgstr "" @@ -1873,7 +1902,7 @@ msgid "Tag" msgstr "" #: assets/js/src/components/admin/Subscribers.js:94 -#: assets/js/src/components/admin/Settings.js:551 +#: assets/js/src/components/admin/Settings.js:587 #: assets/js/src/components/public/EventForm.js:934 #: assets/js/src/components/public/AnnouncementForm.js:657 #: assets/js/src/components/public/SubscribeForm.js:181 @@ -1887,7 +1916,7 @@ msgstr "" msgid "Tags (comma-separated slugs):" msgstr "" -#: assets/js/src/components/admin/Settings.js:610 +#: assets/js/src/components/admin/Settings.js:646 msgid "Tags available for subscription:" msgstr "" @@ -1899,10 +1928,26 @@ msgstr "" msgid "Tags: %s" msgstr "" +#: assets/js/src/components/admin/Settings.js:434 +msgid "Test connection" +msgstr "" + +#: assets/js/src/components/admin/Settings.js:434 +msgid "Testing…" +msgstr "" + #: includes/Widgets/AnnouncementWidget.php:149 msgid "Text Color (hex):" msgstr "" +#: includes/Rest/Helpers/RootServerValidator.php:64 +msgid "The BMLT root server URL must start with \"https://\" (got \"%s\")." +msgstr "" + +#: includes/Rest/Helpers/RootServerValidator.php:112 +msgid "The URL %s did not respond like a BMLT root server." +msgstr "" + #: assets/js/src/components/public/EventForm.js:392 msgid "The selected file is not a valid image, so one will not be submitted. Please choose a valid image file (JPG, PNG, or GIF)" msgstr "" @@ -2024,7 +2069,7 @@ msgstr "" msgid "Type" msgstr "" -#: assets/js/src/components/admin/Settings.js:468 +#: assets/js/src/components/admin/Settings.js:504 #: assets/js/src/components/public/EventList.js:604 #: assets/js/src/components/public/EventArchive.js:77 msgid "Type:" @@ -2038,7 +2083,7 @@ msgstr "" msgid "URL is required" msgstr "" -#: assets/js/src/components/admin/Settings.js:390 +#: assets/js/src/components/admin/Settings.js:415 msgid "URL must start with 'https://'" msgstr "" @@ -2170,7 +2215,7 @@ msgstr "" msgid "Welcome to %s announcements" msgstr "" -#: assets/js/src/components/admin/Settings.js:646 +#: assets/js/src/components/admin/Settings.js:682 msgid "When new options are added:" msgstr "" @@ -2260,7 +2305,7 @@ msgstr "" msgid "days" msgstr "" -#: assets/js/src/components/admin/Settings.js:429 +#: assets/js/src/components/admin/Settings.js:465 msgid "e.g., 1,2,3,0" msgstr "" diff --git a/readme.txt b/readme.txt index 8d79220..fe4027a 100644 --- a/readme.txt +++ b/readme.txt @@ -188,6 +188,7 @@ This project is licensed under the GPL v2 or later. == Changelog == = 1.9.1 = +* Added validation of the BMLT root server URL on save: malformed URLs and hosts that aren't a reachable BMLT root server are now rejected with a clear error instead of being saved silently, plus a "Test connection" button in Settings to verify the server without saving. [#282] * Fixed external feed sources ignoring the visitor's event type and service body filters; user-selected filters now override each source's admin-pinned defaults so a "Service" filter no longer surfaces "Activity" events pulled from a remote feed. [#279] * Added a subscribable ICS calendar feed so Google Calendar, Apple Calendar, and Outlook keep syncing newly approved events automatically. The calendar icon on the event list now opens a Subscribe panel with the feed URL, a webcal:// quick-subscribe link, a one-time .ics download, and Google Calendar instructions. Recurring events now emit proper RRULE/EXDATE so series display as a single repeating event in subscribers' calendars instead of one-shot duplicates, and each event's original timezone is preserved via VTIMEZONE blocks. [#277] diff --git a/tests/Unit/Rest/Helpers/RootServerValidatorTest.php b/tests/Unit/Rest/Helpers/RootServerValidatorTest.php new file mode 100644 index 0000000..f7e0cec --- /dev/null +++ b/tests/Unit/Rest/Helpers/RootServerValidatorTest.php @@ -0,0 +1,103 @@ +mockWpRemoteGet([ + 'GetServerInfo' => [ + 'code' => 200, + 'body' => [['version' => '3.0.0', 'versionInt' => '3000000']], + ], + ]); + + // Trailing slash should be stripped during normalization. + $result = RootServerValidator::validate('https://bmlt.example.com/main_server/'); + + $this->assertSame('https://bmlt.example.com/main_server', $result); + } + + /** + * An empty value is rejected with a clear error. + */ + public function testEmptyUrlIsRejected(): void { + $result = RootServerValidator::validate(''); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_root_server_url', $result->get_error_code()); + } + + /** + * A malformed / non-URL string is rejected without a network call. + */ + public function testMalformedUrlIsRejected(): void { + $result = RootServerValidator::validate('not a valid url'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_root_server_url', $result->get_error_code()); + } + + /** + * A non-https URL is rejected (mirrors the client-side https-only rule). + */ + public function testNonHttpsUrlIsRejected(): void { + $result = RootServerValidator::validate('http://bmlt.example.com'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('invalid_root_server_url', $result->get_error_code()); + } + + /** + * A well-formed URL whose host can't be reached is rejected. + */ + public function testUnreachableHostIsRejected(): void { + // Empty response map => wp_remote_get returns a WP_Error for any URL. + $this->mockWpRemoteGet([]); + + $result = RootServerValidator::validate('https://unreachable.example.com'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('root_server_unreachable', $result->get_error_code()); + } + + /** + * A reachable host returning a non-200 status is rejected. + */ + public function testNon200ResponseIsRejected(): void { + $this->mockWpRemoteGet([ + 'GetServerInfo' => [ + 'code' => 404, + 'body' => 'Not Found', + ], + ]); + + $result = RootServerValidator::validate('https://notbmlt.example.com'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('root_server_unreachable', $result->get_error_code()); + } + + /** + * A reachable host that doesn't respond like a BMLT root server is rejected. + */ + public function testReachableButNotBmltIsRejected(): void { + $this->mockWpRemoteGet([ + 'GetServerInfo' => [ + 'code' => 200, + 'body' => ['result' => 'this is not bmlt'], + ], + ]); + + $result = RootServerValidator::validate('https://notbmlt.example.com'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('not_a_bmlt_root_server', $result->get_error_code()); + } +} diff --git a/tests/Unit/Rest/SettingsControllerTest.php b/tests/Unit/Rest/SettingsControllerTest.php index 117d557..6a38a0b 100644 --- a/tests/Unit/Rest/SettingsControllerTest.php +++ b/tests/Unit/Rest/SettingsControllerTest.php @@ -87,6 +87,15 @@ public function testUpdateSettingsFailsForEditor(): void { public function testAdminCanUpdateSettings(): void { $this->loginAsAdmin(); + // The new root server differs from the stored value, so the save path + // validates it via a GetServerInfo handshake. + $this->mockWpRemoteGet([ + 'GetServerInfo' => [ + 'code' => 200, + 'body' => [['version' => '3.0.0']], + ], + ]); + $request = $this->createRestRequest('POST', '/event-manager/v1/settings', [ 'bmlt_root_server' => 'https://updated.bmlt.com', 'notification_email' => 'updated@example.com', @@ -242,4 +251,106 @@ public function testNotificationEmailValidation(): void { $this->assertStringContainsString('email2@example.com', $data['settings']['notification_email']); $this->assertStringContainsString('email3@example.com', $data['settings']['notification_email']); } + + /** + * Test a malformed root server is rejected and the previous value is preserved + */ + public function testUpdateSettingsRejectsMalformedRootServer(): void { + $this->loginAsAdmin(); + + $request = $this->createRestRequest('POST', '/event-manager/v1/settings', [ + 'bmlt_root_server' => 'not a valid url' + ]); + + $response = SettingsController::update_settings($request); + + $this->assertInstanceOf(\WP_Error::class, $response); + $this->assertEquals('invalid_root_server_url', $response->get_error_code()); + + // Previous value preserved (nothing persisted) + $this->assertEquals('https://bmlt.example.com', $this->options['mayo_settings']['bmlt_root_server']); + } + + /** + * Test an unreachable root server is rejected and the previous value is preserved + */ + public function testUpdateSettingsRejectsUnreachableRootServer(): void { + $this->loginAsAdmin(); + + // Empty response map => wp_remote_get returns a WP_Error for any URL. + $this->mockWpRemoteGet([]); + + $request = $this->createRestRequest('POST', '/event-manager/v1/settings', [ + 'bmlt_root_server' => 'https://unreachable.bmlt.com' + ]); + + $response = SettingsController::update_settings($request); + + $this->assertInstanceOf(\WP_Error::class, $response); + $this->assertEquals('root_server_unreachable', $response->get_error_code()); + + // Previous value preserved (nothing persisted) + $this->assertEquals('https://bmlt.example.com', $this->options['mayo_settings']['bmlt_root_server']); + } + + /** + * Test the validate-root-server endpoint succeeds for a valid BMLT root + */ + public function testValidateRootServerEndpointSucceeds(): void { + $this->loginAsAdmin(); + + $this->mockWpRemoteGet([ + 'GetServerInfo' => [ + 'code' => 200, + 'body' => [['version' => '3.0.0']], + ], + ]); + + $request = $this->createRestRequest('POST', '/event-manager/v1/validate-root-server', [ + 'bmlt_root_server' => 'https://valid.bmlt.com' + ]); + + $response = SettingsController::validate_root_server($request); + + $this->assertInstanceOf(\WP_REST_Response::class, $response); + $this->assertEquals(200, $response->get_status()); + + $data = $response->get_data(); + $this->assertTrue($data['success']); + $this->assertEquals('https://valid.bmlt.com', $data['bmlt_root_server']); + } + + /** + * Test the validate-root-server endpoint returns an error for an unreachable host + */ + public function testValidateRootServerEndpointFailsForUnreachableHost(): void { + $this->loginAsAdmin(); + + $this->mockWpRemoteGet([]); + + $request = $this->createRestRequest('POST', '/event-manager/v1/validate-root-server', [ + 'bmlt_root_server' => 'https://unreachable.bmlt.com' + ]); + + $response = SettingsController::validate_root_server($request); + + $this->assertInstanceOf(\WP_Error::class, $response); + $this->assertEquals('root_server_unreachable', $response->get_error_code()); + } + + /** + * Test the validate-root-server endpoint requires admin permissions + */ + public function testValidateRootServerEndpointRequiresAdmin(): void { + $this->logoutUser(); + + $request = $this->createRestRequest('POST', '/event-manager/v1/validate-root-server', [ + 'bmlt_root_server' => 'https://valid.bmlt.com' + ]); + + $response = SettingsController::validate_root_server($request); + + $this->assertInstanceOf(\WP_Error::class, $response); + $this->assertEquals('rest_forbidden', $response->get_error_code()); + } } diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index ebd13c8..6f5da3f 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -111,6 +111,24 @@ protected function setupCommonStubs(): void { return filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : false; }); + // URL parsing/validation helpers + Functions\when('wp_parse_url')->alias(function($url, $component = -1) { + return parse_url($url, $component); + }); + Functions\when('untrailingslashit')->alias(function($string) { + return rtrim($string, '/\\'); + }); + Functions\when('wp_http_validate_url')->alias(function($url) { + $parsed = parse_url($url); + if (empty($parsed['scheme']) || empty($parsed['host'])) { + return false; + } + if (!in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) { + return false; + } + return $url; + }); + // URL functions Functions\stubs([ 'home_url' => 'https://example.com', From f1a5172dd94ee6ce2ee3e8a167942aa04bf3dbe6 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Sat, 23 May 2026 11:30:47 -0400 Subject: [PATCH 2/2] Move "Test connection" button to the right of the root server field [#282] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place the BMLT root server URL input and the Test connection button in a single flex row (button vertically centered on the input) instead of stacking the button below the field. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 202F8173-BB91-4E6F-B986-1CE19C339F1A --- assets/js/src/components/admin/Settings.js | 55 +++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/assets/js/src/components/admin/Settings.js b/assets/js/src/components/admin/Settings.js index 883e561..f16510f 100644 --- a/assets/js/src/components/admin/Settings.js +++ b/assets/js/src/components/admin/Settings.js @@ -406,33 +406,34 @@ const Settings = () => { - handleChange('bmlt_root_server', value)} - help={ - settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server) - ? __("URL must start with 'https://'", 'mayo-events-manager') - : __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager') - } - className={ - settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server) - ? 'mayo-invalid-url' - : '' - } - __next40pxDefaultSize={true} - /> - - - - +
+
+ handleChange('bmlt_root_server', value)} + help={ + settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server) + ? __("URL must start with 'https://'", 'mayo-events-manager') + : __('Enter the URL of your BMLT root server (e.g., https://bmlt.example.org/main_server)', 'mayo-events-manager') + } + className={ + settings.bmlt_root_server && !isValidHttpsUrl(settings.bmlt_root_server) + ? 'mayo-invalid-url' + : '' + } + __next40pxDefaultSize={true} + /> +
+ +