diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 7811b3b7254d9..92c324cd2656d 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -547,7 +547,11 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R // On update, validate AI provider keys before masking. // Non-AI connectors accept keys as-is; the service plugin handles its own validation. if ( $is_update && 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 new file mode 100644 index 0000000000000..90355cb46c25f --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsRestSettingsDispatch.php @@ -0,0 +1,191 @@ +is_registered( self::UNVERIFIABLE_ID ) ) { + $registry->register( + self::UNVERIFIABLE_ID, + array( + 'name' => 'Mock Unverifiable', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'setting_name' => self::UNVERIFIABLE_SETTING, + ), + ) + ); + } + } + + /** + * Builds a POST request and response for the settings endpoint. + * + * @param string $setting_name The connector setting key. + * @param string $value The submitted/stored key value. + * @return array{0: WP_REST_Response, 1: WP_REST_Request} The response and request. + */ + private function make_settings_update( string $setting_name, string $value ): array { + $request = new WP_REST_Request( 'POST', '/wp/v2/settings' ); + + $response = new WP_REST_Response( array( $setting_name => $value ) ); + + return array( $response, $request ); + } + + /** + * A validation result that is not an explicit failure must preserve the stored key. + * + * @ticket 65551 + */ + public function test_indeterminate_validation_preserves_key() { + $this->setExpectedIncorrectUsage( '_wp_connectors_is_ai_api_key_valid' ); + + update_option( self::UNVERIFIABLE_SETTING, 'existing-valid-key' ); + + list( $response, $request ) = $this->make_settings_update( 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.' + ); + } + + /** + * An explicitly invalid key must be discarded. + * + * @ticket 65551 + */ + public function test_invalid_key_is_discarded() { + self::set_mock_provider_configured( false ); + + update_option( self::MOCK_SETTING, 'bad-key' ); + + list( $response, $request ) = $this->make_settings_update( self::MOCK_SETTING, 'bad-key' ); + + $result = _wp_connectors_rest_settings_dispatch( $response, rest_get_server(), $request ); + + $this->assertSame( + '', + get_option( self::MOCK_SETTING ), + 'An invalid key should be discarded.' + ); + + $data = $result->get_data(); + $this->assertSame( '', $data[ self::MOCK_SETTING ] ); + } + + /** + * A valid key must be preserved and masked in the response. + * + * @ticket 65551 + */ + public function test_valid_key_is_preserved_and_masked() { + self::set_mock_provider_configured( true ); + + update_option( self::MOCK_SETTING, 'a-valid-secret-key' ); + + list( $response, $request ) = $this->make_settings_update( self::MOCK_SETTING, 'a-valid-secret-key' ); + + $result = _wp_connectors_rest_settings_dispatch( $response, rest_get_server(), $request ); + + $this->assertSame( + 'a-valid-secret-key', + get_option( self::MOCK_SETTING ), + 'A valid key should be preserved in the database.' + ); + + $data = $result->get_data(); + $this->assertNotSame( + 'a-valid-secret-key', + $data[ self::MOCK_SETTING ], + 'The raw key should never be exposed in the response.' + ); + $this->assertStringEndsWith( + '-key', + $data[ self::MOCK_SETTING ], + 'The masked value should retain the final characters of the key.' + ); + } + + /** + * Read (GET) requests must never validate or discard the stored key. + * + * @ticket 65551 + */ + public function test_get_request_does_not_discard_key() { + self::set_mock_provider_configured( false ); + + update_option( self::MOCK_SETTING, 'a-valid-secret-key' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = new WP_REST_Response( array( self::MOCK_SETTING => 'a-valid-secret-key' ) ); + + _wp_connectors_rest_settings_dispatch( $response, rest_get_server(), $request ); + + $this->assertSame( + 'a-valid-secret-key', + get_option( self::MOCK_SETTING ), + 'A GET request must not discard the stored key, even for an unconfigured provider.' + ); + } +}