From 57df74bc3b0c9bc7b200c59b842ff379d5fafe3f Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Mon, 29 Jun 2026 12:55:59 +0530 Subject: [PATCH] Connectors: Preserve stored AI provider API key when validation is indeterminate. `_wp_connectors_rest_settings_dispatch()` discarded a stored AI provider API key whenever validation did not return strictly `true`. Since `_wp_connectors_is_ai_api_key_valid()` returns `null` when a key cannot be verified, a transient provider outage during a settings save permanently wiped a valid key. Only discard the key on an explicit `false` result so that an unverifiable key is preserved. Adds coverage for the dispatch handler. Props itzmekhokan. See #65551. --- src/wp-includes/connectors.php | 6 +- .../wpConnectorsRestSettingsDispatch.php | 79 ++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 9aed5f1f4d7aa..df2989de9ab7b 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -738,7 +738,11 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) { - if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { + /* + * Discard the key only when validation explicitly reports it as invalid. + * Any other result preserves the stored key. + */ + if ( false === _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { update_option( $setting_name, '' ); $data[ $setting_name ] = ''; continue; diff --git a/tests/phpunit/tests/connectors/wpConnectorsRestSettingsDispatch.php b/tests/phpunit/tests/connectors/wpConnectorsRestSettingsDispatch.php index eb723701731f1..9464cf7c84c40 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsRestSettingsDispatch.php +++ b/tests/phpunit/tests/connectors/wpConnectorsRestSettingsDispatch.php @@ -16,6 +16,13 @@ class Tests_Connectors_WpConnectorsRestSettingsDispatch extends WP_UnitTestCase const CREDENTIALS_SETTING_NAME = 'connectors_test_remote_credentials'; const AI_KEY_SETTING_NAME = 'connectors_ai_mock_connectors_test_api_key'; + /* + * A connector registered as an AI provider but absent from the AI Client + * registry, so its key can never be verified (validation returns null). + */ + const UNVERIFIABLE_ID = 'mock-connectors-unverifiable'; + const UNVERIFIABLE_SETTING = 'connectors_test_unverifiable_api_key'; + /** * Registers the mock AI provider connector once before any tests in this class run. */ @@ -33,7 +40,7 @@ public static function tear_down_after_class(): void { } /** - * Registers an application password connector before each test. + * Registers the test connectors before each test. */ public function set_up(): void { parent::set_up(); @@ -51,16 +58,32 @@ public function set_up(): void { ), ) ); + + WP_Connector_Registry::get_instance()->register( + self::UNVERIFIABLE_ID, + array( + 'name' => 'Mock Unverifiable', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => self::UNVERIFIABLE_SETTING, + ), + ) + ); } /** - * Removes the test connector after each test. + * Removes the test connectors after each test. */ public function tear_down(): void { $registry = WP_Connector_Registry::get_instance(); if ( null !== $registry && $registry->is_registered( self::CONNECTOR_ID ) ) { $registry->unregister( self::CONNECTOR_ID ); } + if ( null !== $registry && $registry->is_registered( self::UNVERIFIABLE_ID ) ) { + $registry->unregister( self::UNVERIFIABLE_ID ); + } parent::tear_down(); } @@ -183,4 +206,56 @@ public function test_keeps_and_masks_submitted_valid_ai_key(): void { 'The submitted AI provider key should be masked in the response.' ); } + + /** + * A validation result that is not an explicit failure must preserve the stored key. + * + * @ticket 65551 + */ + public function test_indeterminate_validation_preserves_key(): void { + $this->setExpectedIncorrectUsage( '_wp_connectors_is_ai_api_key_valid' ); + + update_option( self::UNVERIFIABLE_SETTING, 'existing-valid-key' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/settings' ); + $request->set_param( self::UNVERIFIABLE_SETTING, 'existing-valid-key' ); + $response = new WP_REST_Response( array( self::UNVERIFIABLE_SETTING => 'existing-valid-key' ) ); + + $result = _wp_connectors_rest_settings_dispatch( $response, rest_get_server(), $request ); + + $this->assertSame( + 'existing-valid-key', + get_option( self::UNVERIFIABLE_SETTING ), + 'The stored key should be preserved when validation is indeterminate.' + ); + + $data = $result->get_data(); + $this->assertNotSame( + '', + $data[ self::UNVERIFIABLE_SETTING ], + 'The response value should not be emptied when validation is indeterminate.' + ); + } + + /** + * Read (GET) requests must never validate or discard the stored key. + * + * @ticket 65551 + */ + public function test_get_request_does_not_discard_key(): void { + self::set_mock_provider_configured( false ); + + update_option( self::AI_KEY_SETTING_NAME, 'a-valid-secret-key' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = new WP_REST_Response( array( self::AI_KEY_SETTING_NAME => 'a-valid-secret-key' ) ); + + _wp_connectors_rest_settings_dispatch( $response, rest_get_server(), $request ); + + $this->assertSame( + 'a-valid-secret-key', + get_option( self::AI_KEY_SETTING_NAME ), + 'A GET request must not discard the stored key, even for an unconfigured provider.' + ); + } }