diff --git a/class.jetpack-options.php b/class.jetpack-options.php index 1e51ec27a008..fcb7fbc0ab38 100644 --- a/class.jetpack-options.php +++ b/class.jetpack-options.php @@ -1,5 +1,40 @@ connection_manager = new Connection_Manager( ); + /** * Prepare Gutenberg Editor functionality */ @@ -4536,6 +4555,7 @@ function build_connect_url( $raw = false, $redirect = false, $from = false, $reg ); if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + // Generating a register URL instead to refresh the existing token return $this->build_connect_url( $raw, $redirect, $from, true ); } @@ -4945,55 +4965,29 @@ public static function xmlrpc_api_url() { * @return array */ public static function generate_secrets( $action, $user_id = false, $exp = 600 ) { - if ( ! $user_id ) { + if ( false === $user_id ) { $user_id = get_current_user_id(); } - $secret_name = 'jetpack_' . $action . '_' . $user_id; - $secrets = Jetpack_Options::get_raw_option( 'jetpack_secrets', array() ); - - if ( - isset( $secrets[ $secret_name ] ) && - $secrets[ $secret_name ]['exp'] > time() - ) { - return $secrets[ $secret_name ]; - } - - $secret_value = array( - 'secret_1' => wp_generate_password( 32, false ), - 'secret_2' => wp_generate_password( 32, false ), - 'exp' => time() + $exp, - ); - - $secrets[ $secret_name ] = $secret_value; - - Jetpack_Options::update_raw_option( 'jetpack_secrets', $secrets ); - return $secrets[ $secret_name ]; + return self::init()->connection_manager->generate_secrets( $action, $user_id, $exp ); } public static function get_secrets( $action, $user_id ) { - $secret_name = 'jetpack_' . $action . '_' . $user_id; - $secrets = Jetpack_Options::get_raw_option( 'jetpack_secrets', array() ); + $secrets = self::init()->connection_manager->get_secrets( $action, $user_id ); - if ( ! isset( $secrets[ $secret_name ] ) ) { + if ( Connection_Manager::SECRETS_MISSING === $secrets ) { return new WP_Error( 'verify_secrets_missing', 'Verification secrets not found' ); } - if ( $secrets[ $secret_name ]['exp'] < time() ) { - self::delete_secrets( $action, $user_id ); + if ( Connection_Manager::SECRETS_EXPIRED === $secrets ) { return new WP_Error( 'verify_secrets_expired', 'Verification took too long' ); } - return $secrets[ $secret_name ]; + return $secrets; } public static function delete_secrets( $action, $user_id ) { - $secret_name = 'jetpack_' . $action . '_' . $user_id; - $secrets = Jetpack_Options::get_raw_option( 'jetpack_secrets', array() ); - if ( isset( $secrets[ $secret_name ] ) ) { - unset( $secrets[ $secret_name ] ); - Jetpack_Options::update_raw_option( 'jetpack_secrets', $secrets ); - } + return self::init()->connection_manager->delete_secrets( $action, $user_id ); } /** diff --git a/composer.json b/composer.json index 2bbe94c9cfe1..b53ae66334e9 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,12 @@ "issues": "https://github.com/Automattic/jetpack/issues" }, "require": { + "composer/installers": "1.6.0", "ext-openssl": "*", - "automattic/jetpack-logo": "@dev" + "automattic/jetpack-connection": "^1.0", + "automattic/jetpack-options": "^1.0", + "automattic/jetpack-logo": "@dev", + "automattic/jetpack-constants": "@dev" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "0.5.0", @@ -18,6 +22,22 @@ "phpcompatibility/phpcompatibility-wp": "2.0.0", "automattic/jetpack-autoloader": "@dev" }, + "repositories": [ + { + "type": "path", + "url": "./packages/jetpack-connection", + "options": { + "symlink": true + } + }, + { + "type": "path", + "url": "./packages/jetpack-options", + "options": { + "symlink": true + } + } + ], "scripts": { "php:compatibility": "composer install && vendor/bin/phpcs -p -s --runtime-set testVersion '5.3-' --standard=PHPCompatibilityWP --ignore=docker,tools,tests,node_modules,vendor --extensions=php", "php:lint": "composer install && vendor/bin/phpcs -p -s", diff --git a/composer.lock b/composer.lock index 0faa212bae45..82f9ed388be9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,11 +4,58 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "254aebf32be452ab169d10927a20164f", + "content-hash": "2a6b3658e33a86548e8cb2a6a88a1b1c", "packages": [ + { + "name": "automattic/jetpack-connection", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "./packages/connection", + "reference": "9dbcf84ab69da7d648e99580533624e2953c9874" + }, + "require-dev": { + "10up/wp_mock": "0.4.2", + "phpunit/phpunit": "7.*.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Connection\\": "src" + } + }, + "scripts": { + "test": [ + "phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Everything needed to connect to the Jetpack infrastructure" + }, + { + "name": "automattic/jetpack-constants", + "version": "dev-master", + "dist": { + "type": "path", + "url": "./packages/constants", + "reference": "b2cebb6eb5d4da90374e17c232168088f76c4c49" + }, + "type": "library", + "autoload": { + "classmap": [ + "/legacy" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "A wrapper for defining constants in a more testable way." + }, { "name": "automattic/jetpack-logo", - "version": "dev-try/custom-autoloader", + "version": "dev-master", "dist": { "type": "path", "url": "./packages/logo", @@ -24,12 +71,162 @@ "GPL-2.0-or-later" ], "description": "A logo for Jetpack" + }, + { + "name": "automattic/jetpack-options", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "./packages/options", + "reference": "0ff5d88cb668dcb7f8f54dfa4a19c87819e1dc78" + }, + "require": { + "automattic/jetpack-constants": "@dev" + }, + "require-dev": { + "10up/wp_mock": "0.4.2", + "phpunit/phpunit": "7.*.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Options\\": "src" + } + }, + "scripts": { + "test": [ + "phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ] + }, + { + "name": "composer/installers", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "cfcca6b1b60bc4974324efb5783c13dca6932b5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/cfcca6b1b60bc4974324efb5783c13dca6932b5b", + "reference": "cfcca6b1b60bc4974324efb5783c13dca6932b5b", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0" + }, + "replace": { + "roundcube/plugin-installer": "*", + "shama/baton": "*" + }, + "require-dev": { + "composer/composer": "1.0.*@dev", + "phpunit/phpunit": "^4.8.36" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Craft", + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Thelia", + "WolfCMS", + "agl", + "aimeos", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "joomla", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "mediawiki", + "modulework", + "modx", + "moodle", + "osclass", + "phpbb", + "piwik", + "ppi", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "symfony", + "typo3", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "time": "2018-08-27T06:10:37+00:00" } ], "packages-dev": [ { "name": "automattic/jetpack-autoloader", - "version": "dev-try/custom-autoloader", + "version": "dev-master", "dist": { "type": "path", "url": "./packages/autoloader", @@ -424,6 +621,7 @@ "minimum-stability": "stable", "stability-flags": { "automattic/jetpack-logo": 20, + "automattic/jetpack-constants": 20, "automattic/jetpack-autoloader": 20 }, "prefer-stable": false, diff --git a/jetpack.php b/jetpack.php index 7059426e686c..a03183257e9c 100644 --- a/jetpack.php +++ b/jetpack.php @@ -12,9 +12,11 @@ * Domain Path: /languages/ */ +require plugin_dir_path( __FILE__ ) . '/vendor/autoload.php'; define( 'JETPACK__MINIMUM_WP_VERSION', '5.1' ); define( 'JETPACK__MINIMUM_PHP_VERSION', '5.3.2' ); + define( 'JETPACK__VERSION', '7.4-alpha' ); define( 'JETPACK_MASTER_USER', true ); define( 'JETPACK__API_VERSION', 1 ); @@ -224,7 +226,6 @@ function jetpack_admin_missing_autoloader() { ?> require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-tracks.php' ); require_once( JETPACK__PLUGIN_DIR . 'class.frame-nonce-preview.php' ); require_once( JETPACK__PLUGIN_DIR . 'modules/module-headings.php'); -require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-constants.php'); require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-idc.php' ); require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-connection-banner.php' ); require_once( JETPACK__PLUGIN_DIR . 'class.jetpack-plan.php' ); diff --git a/packages/.gitignore b/packages/.gitignore index f4bdfcb54f7d..7ecda0efc77b 100644 --- a/packages/.gitignore +++ b/packages/.gitignore @@ -1,2 +1,3 @@ -## Ignore composer.lock for our library packages -/**/composer.lock \ No newline at end of file +## Ignore composer.lock and vendor folders for our library packages +/**/composer.lock +/**/vendor/ \ No newline at end of file diff --git a/packages/connection/composer.json b/packages/connection/composer.json new file mode 100644 index 000000000000..fe6c91d3e3d0 --- /dev/null +++ b/packages/connection/composer.json @@ -0,0 +1,20 @@ +{ + "name": "automattic/jetpack-connection", + "description": "Everything needed to connect to the Jetpack infrastructure", + "type": "library", + "license": "GPL-2.0-or-later", + "version": "1.0.0", + "require": {}, + "require-dev": { + "phpunit/phpunit": "7.*.*", + "10up/wp_mock": "0.4.2" + }, + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Connection\\": "src" + } + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/packages/connection/phpunit.xml b/packages/connection/phpunit.xml new file mode 100644 index 000000000000..e4f3004e20a1 --- /dev/null +++ b/packages/connection/phpunit.xml @@ -0,0 +1,7 @@ + + + + tests/Manager.php + + + diff --git a/packages/connection/src/Manager.php b/packages/connection/src/Manager.php new file mode 100644 index 000000000000..0c093579cf6a --- /dev/null +++ b/packages/connection/src/Manager.php @@ -0,0 +1,281 @@ +secret_callable ) ) { + /** + * Allows modification of the callable that is used to generate connection secrets. + * + * @param Callable a function or method that returns a secret string. + */ + $this->secret_callable = apply_filters( 'jetpack_connection_secret_generator', 'wp_generate_password' ); + } + + return $this->secret_callable; + } + + /** + * Returns the object that is to be used for all option manipulation. + * + * @return Object $manager an option manager object. + */ + protected function get_option_manager() { + if ( ! isset( $this->option_manager ) ) { + /** + * Allows modification of the object that is used to manipulate stored data. + * + * @param Jetpack_Options an option manager object. + */ + $this->option_manager = apply_filters( 'jetpack_connection_option_manager', false ); + } + + return $this->option_manager; + } + + /** + * Generates two secret tokens and the end of life timestamp for them. + * + * @param String $action The action name. + * @param Integer $user_id The user identifier. + * @param Integer $exp Expiration time in seconds. + */ + public function generate_secrets( $action, $user_id, $exp ) { + $callable = $this->get_secret_callable(); + + $secrets = $this->get_option_manager()->get_raw_option( 'jetpack_secrets', array() ); + + $secret_name = 'jetpack_' . $action . '_' . $user_id; + + if ( + isset( $secrets[ $secret_name ] ) && + $secrets[ $secret_name ]['exp'] > time() + ) { + return $secrets[ $secret_name ]; + } + + $secret_value = array( + 'secret_1' => call_user_func( $callable ), + 'secret_2' => call_user_func( $callable ), + 'exp' => time() + $exp, + ); + + $secrets[ $secret_name ] = $secret_value; + + $this->get_option_manager()->update_option( self::SECRETS_OPTION_NAME, $secrets ); + return $secrets[ $secret_name ]; + } + + /** + * Returns two secret tokens and the end of life timestamp for them. + * + * @param String $action The action name. + * @param Integer $user_id The user identifier. + * @return string|array an array of secrets or an error string. + */ + public function get_secrets( $action, $user_id ) { + $secret_name = 'jetpack_' . $action . '_' . $user_id; + $secrets = $this->get_option_manager()->get_option( self::SECRETS_OPTION_NAME, array() ); + + if ( ! isset( $secrets[ $secret_name ] ) ) { + return self::SECRETS_MISSING; + } + + if ( $secrets[ $secret_name ]['exp'] < time() ) { + $this->delete_secrets( $action, $user_id ); + return self::SECRETS_EXPIRED; + } + + return $secrets[ $secret_name ]; + } + + /** + * Deletes secret tokens in case they, for example, have expired. + * + * @param String $action The action name. + * @param Integer $user_id The user identifier. + */ + public function delete_secrets( $action, $user_id ) { + $secret_name = 'jetpack_' . $action . '_' . $user_id; + $secrets = $this->get_option_manager()->get_option( self::SECRETS_OPTION_NAME, array() ); + if ( isset( $secrets[ $secret_name ] ) ) { + unset( $secrets[ $secret_name ] ); + $this->get_option_manager()->update_option( self::SECRETS_OPTION_NAME, $secrets ); + } + } + + /** + * Responds to a WordPress.com call to register the current site. + * Should be changed to protected. + */ + public function handle_registration() { + + } + + /** + * Responds to a WordPress.com call to authorize the current user. + * Should be changed to protected. + */ + public function handle_authorization() { + + } + + /** + * Builds a URL to the Jetpack connection auth page. + * This needs rethinking. + * + * @param bool $raw If true, URL will not be escaped. + * @param bool|string $redirect If true, will redirect back to Jetpack wp-admin landing page after connection. + * If string, will be a custom redirect. + * @param bool|string $from If not false, adds 'from=$from' param to the connect URL. + * @param bool $register If true, will generate a register URL regardless of the existing token, since 4.9.0. + * + * @return string Connect URL + */ + public function build_connect_url( $raw, $redirect, $from, $register ) { + return array( $raw, $redirect, $from, $register ); + } + + /** + * Disconnects from the Jetpack servers. + * Forgets all connection details and tells the Jetpack servers to do the same. + */ + public function disconnect_site() { + + } +} diff --git a/packages/connection/src/Manager_Interface.php b/packages/connection/src/Manager_Interface.php new file mode 100644 index 000000000000..b7fae840202d --- /dev/null +++ b/packages/connection/src/Manager_Interface.php @@ -0,0 +1,144 @@ +mock = $this->getMockBuilder( 'stdClass' ) + ->setMethods( [ 'get_option', 'update_option' ] ) + ->getMock(); + + $this->generator = $this->getMockBuilder( 'stdClass' ) + ->setMethods( [ 'generate' ] ) + ->getMock(); + + $this->manager = new Manager(); + + \WP_Mock::onFilter( 'jetpack_connection_option_manager' ) + ->with( false ) + ->reply( $this->mock ); + + \WP_Mock::onFilter( 'jetpack_connection_secret_generator' ) + ->with( 'wp_generate_password' ) + ->reply( array( $this->generator, 'generate' ) ); + } + + public function tearDown() { + \WP_Mock::tearDown(); + } + + function test_class_implements_interface() { + $manager = new Manager(); + $this->assertInstanceOf( 'Automattic\Jetpack\Connection\Manager_Interface', $manager ); + } + + function test_generate_secrets() { + $this->generator->expects( $this->exactly( 2 ) ) + ->method( 'generate' ) + ->will( $this->returnValue( 'topsecretstring' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'update_option' ) + ->with( + $this->equalTo( Manager::SECRETS_OPTION_NAME ), + $this->equalTo( array( + 'jetpack_name_1' => array( + 'secret_1' => 'topsecretstring', + 'secret_2' => 'topsecretstring', + 'exp' => time() + 600 + ) + ) ) + ); + + $secrets = $this->manager->generate_secrets( 'name', 1, 600 ); + + $this->assertEquals( 'topsecretstring', $secrets['secret_1'] ); + $this->assertEquals( 'topsecretstring', $secrets['secret_2'] ); + } + + function test_get_secrets_not_found() { + $this->mock->expects( $this->once() ) + ->method( 'get_option' ) + ->with( + $this->equalTo( Manager::SECRETS_OPTION_NAME ), + $this->anything() + ); + + $this->assertEquals( + Manager::SECRETS_MISSING, + $this->manager->get_secrets( 'name', 1, 600 ) + ); + } + + /** + * @dataProvider secrets_value_provider + */ + function test_get_secrets_expired( $name, $user_id, $expires, $values ) { + $this->mock->expects( $this->exactly( 2 ) ) + ->method( 'get_option' ) + ->with( + $this->equalTo( Manager::SECRETS_OPTION_NAME ), + $this->anything() + ) + ->will( + $this->returnValue( array( + 'jetpack_' . $name . '_' . $user_id => array_merge( + $values, + + // Expired secret, should be removed on access. + array( 'exp' => 0 ) + ) + ) ) + ); + + $this->mock->expects( $this->once() ) + ->method( 'update_option' ) + ->with( + $this->equalTo( Manager::SECRETS_OPTION_NAME ), + $this->equalTo( array() ) + ); + + $this->assertEquals( + Manager::SECRETS_EXPIRED, + $this->manager->get_secrets( $name, $user_id, $expires ) + ); + } + + /** + * @dataProvider secrets_value_provider + */ + function test_get_secrets( $name, $user_id, $expires, $values ) { + $this->mock->expects( $this->once() ) + ->method( 'get_option' ) + ->with( + $this->equalTo( Manager::SECRETS_OPTION_NAME ), + $this->anything() + ) + ->will( + $this->returnValue( array( + 'jetpack_' . $name . '_' . $user_id => array_merge( + $values, + + // Making sure the secret is still active. + array( 'exp' => $values['exp'] + time() ) + ) + ) ) + ); + + $this->assertEquals( + $values['secret_1'], + $this->manager->get_secrets( $name, $user_id, $expires )['secret_1'] + ); + } + + /** + * Provides values for secrets to test. + */ + function secrets_value_provider() { + return [ + [ + 'action_name', + 123, + 3600, + [ + 'secret_1' => 'secret1', + 'secret_2' => 'secret2', + 'exp' => 600 + ] + ], + [ + 'action_name_2', + 1234, + 36000, + [ + 'secret_1' => 'secret1withsomewords', + 'secret_2' => 'secret2mithosemthingelse', + 'exp' => 36000 + ] + ] + ]; + } +} diff --git a/packages/connection/tests/bootstrap.php b/packages/connection/tests/bootstrap.php new file mode 100644 index 000000000000..d914bd03446f --- /dev/null +++ b/packages/connection/tests/bootstrap.php @@ -0,0 +1,5 @@ + + + + tests/Manager.php + + + diff --git a/packages/options/src/Manager.php b/packages/options/src/Manager.php new file mode 100644 index 000000000000..8f031fe83c5f --- /dev/null +++ b/packages/options/src/Manager.php @@ -0,0 +1,293 @@ + 'jetpack_options', + 'private' => 'jetpack_private_options', + ); + + /** + * Returns an array of option names for a given type. + * + * @param string $type The type of option to return. Defaults to 'compact'. + * + * @return array + */ + abstract public function get_option_names( $type ); + + /** + * Returns the requested option. Looks in jetpack_options or jetpack_$name as appropriate. + * + * @param string $name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name. + * @param mixed $default (optional) the default value. + * + * @return mixed + */ + public function get_option( $name, $default = false ) { + if ( $this->is_valid( $name, 'non_compact' ) ) { + if ( $this->is_network_option( $name ) ) { + return get_site_option( "jetpack_$name", $default ); + } + + return get_option( "jetpack_$name", $default ); + } + + foreach ( array_keys( $this->grouped_options ) as $group ) { + if ( $this->is_valid( $name, $group ) ) { + return $this->get_grouped_option( $group, $name, $default ); + } + } + + // TODO: throw an exception here? + + return $default; + } + + /** + * Returns a single value from a grouped option. + * + * @param String $group name of the group, i.e., 'private'. + * @param String $name the name of the option to return. + * @param Mixed $default a default value in case the option is not found. + * @return Mixed the option value or default if not found. + */ + protected function get_grouped_option( $group, $name, $default ) { + $options = get_option( $this->grouped_options[ $group ] ); + if ( is_array( $options ) && isset( $options[ $name ] ) ) { + return $options[ $name ]; + } + + return $default; + } + + /** + * Updates the single given option. Updates jetpack_options or jetpack_$name as appropriate. + * + * @param string $name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name. + * @param mixed $value Option value. + * @param string $autoload If not compact option, allows specifying whether to autoload or not. + * + * @return bool Was the option successfully updated? + */ + public function update_option( $name, $value, $autoload = null ) { + /** + * Fires before Jetpack updates a specific option. + * + * @since 3.0.0 + * + * @param str $name The name of the option being updated. + * @param mixed $value The new value of the option. + */ + do_action( 'pre_update_jetpack_option_' . $name, $name, $value ); + if ( $this->is_valid( $name, 'non_compact' ) ) { + if ( $this->is_network_option( $name ) ) { + return update_site_option( "jetpack_$name", $value ); + } + + return update_option( "jetpack_$name", $value, $autoload ); + + } + + foreach ( array_keys( $this->grouped_options ) as $group ) { + if ( $this->is_valid( $name, $group ) ) { + return $this->update_grouped_option( $group, $name, $value ); + } + } + + // TODO: throw an exception here? + + return false; + } + + /** + * Updates a single value from a grouped option. + * + * @param String $group name of the group, i.e., 'private'. + * @param String $name the name of the option to update. + * @param Mixed $value the to update the option with. + * @return Boolean was the update successful? + */ + protected function update_grouped_option( $group, $name, $value ) { + $options = get_option( $this->grouped_options[ $group ] ); + if ( ! is_array( $options ) ) { + $options = array(); + } + $options[ $name ] = $value; + + return update_option( $this->grouped_options[ $group ], $options ); + } + + /** + * Deletes the given option. May be passed multiple option names as an array. + * Updates jetpack_options and/or deletes jetpack_$name as appropriate. + * + * @param string|array $names Option names. They must come _without_ `jetpack_%` prefix. The method will prefix the option names. + * + * @return bool Was the option successfully deleted? + */ + public function delete_option( $names ) { + $result = true; + $names = (array) $names; + + if ( ! $this->is_valid( $names ) ) { + // TODO: issue a warning here? + return false; + } + + foreach ( array_intersect( $names, $this->get_option_names( 'non_compact' ) ) as $name ) { + if ( $this->is_network_option( $name ) ) { + $result = delete_site_option( "jetpack_$name" ); + } else { + $result = delete_option( "jetpack_$name" ); + } + } + + foreach ( array_keys( $this->grouped_options ) as $group ) { + if ( ! $this->delete_grouped_option( $group, $names ) ) { + $result = false; + } + } + + return $result; + } + + /** + * Deletes a single value from a grouped option. + * + * @param String $group name of the group, i.e., 'private'. + * @param Array $names the names of the option to delete. + * @return Mixed the option value or default if not found. + */ + protected function delete_grouped_option( $group, $names ) { + $options = get_option( $this->grouped_options[ $group ], array() ); + + $to_delete = array_intersect( $names, $this->get_option_names( $group ), array_keys( $options ) ); + if ( $to_delete ) { + foreach ( $to_delete as $name ) { + unset( $options[ $name ] ); + } + + return update_option( $this->grouped_options[ $group ], $options ); + } + + return true; + } + + /** + * Is the option name valid? + * + * @param string $name The name of the option. + * @param string|null $group The name of the group that the option is in. Default to null, which will search non_compact. + * + * @return bool Is the option name valid? + */ + public function is_valid( $name, $group = null ) { + if ( is_array( $name ) ) { + $compact_names = array(); + foreach ( array_keys( $this->grouped_options ) as $_group ) { + $compact_names = array_merge( $compact_names, $this->get_option_names( $_group ) ); + } + + $result = array_diff( $name, $this->get_option_names( 'non_compact' ), $compact_names ); + + return empty( $result ); + } + + if ( is_null( $group ) || 'non_compact' === $group ) { + if ( in_array( $name, $this->get_option_names( $group ), true ) ) { + return true; + } + } + + foreach ( array_keys( $this->grouped_options ) as $_group ) { + if ( is_null( $group ) || $group === $_group ) { + if ( in_array( $name, $this->get_option_names( $_group ), true ) ) { + return true; + } + } + } + + return false; + } + + /** + * Checks if an option must be saved for the whole network in WP Multisite + * + * @param string $option_name Option name. It must come _without_ `jetpack_%` prefix. The method will prefix the option name. + * + * @return bool + */ + public function is_network_option( $option_name ) { + if ( ! is_multisite() ) { + return false; + } + return in_array( $option_name, $this->get_option_names( 'network' ), true ); + } + + /** + * Gets an option via $wpdb query. + * + * @since 5.4.0 + * + * @param string $name Option name. + * @param mixed $default Default option value if option is not found. + * + * @return mixed Option value, or null if option is not found and default is not specified. + */ + function get_raw_option( $name, $default = null ) { + if ( $this->bypass_raw_option( $name ) ) { + return get_option( $name, $default ); + } + global $wpdb; + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", + $name + ) + ); + $value = maybe_unserialize( $value ); + if ( $value === null && $default !== null ) { + return $default; + } + return $value; + } + /** + * This function checks for a constant that, if present, will disable direct DB queries Jetpack uses to manage certain options and force Jetpack to always use Options API instead. + * Options can be selectively managed via a blacklist by filtering option names via the jetpack_disabled_raw_option filter. + * + * @param $name Option name + * + * @return bool + */ + function bypass_raw_option( $name ) { + if ( \Jetpack_Constants::get_constant( 'JETPACK_DISABLE_RAW_OPTIONS' ) ) { + return true; + } + /** + * Allows to disable particular raw options. + * + * @since 5.5.0 + * + * @param array $disabled_raw_options An array of option names that you can selectively blacklist from being managed via direct database queries. + */ + $disabled_raw_options = apply_filters( 'jetpack_disabled_raw_options', array() ); + return isset( $disabled_raw_options[ $name ] ); + } +} diff --git a/packages/options/tests/Manager.php b/packages/options/tests/Manager.php new file mode 100644 index 000000000000..a78f50d5cd40 --- /dev/null +++ b/packages/options/tests/Manager.php @@ -0,0 +1,246 @@ +manager = new Manager_Test(); + } + + function test_get_private_option_returns_value() { + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options' ), + 'return' => array( 'private_name' => true ), + ) ); + + $value = $this->manager->get_option( 'private_name' ); + + // Did Jetpack_Options::get_option() properly return true? + $this->assertTrue( $value ); + } + + function test_get_network_option_returns_value() { + \WP_Mock::userFunction( 'get_site_option', array( + 'times' => 1, + 'args' => array( 'jetpack_network_name', false ), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => true, + ) ); + + $value = $this->manager->get_option( 'network_name' ); + + // Did Jetpack_Options::get_option() properly return true? + $this->assertTrue( $value ); + } + + function test_get_non_compact_option_returns_value() { + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_uncompact_option_name', false ), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => false, + ) ); + + $value = $this->manager->get_option( 'uncompact_option_name' ); + + // Did Jetpack_Options::get_option() properly return true? + $this->assertTrue( $value ); + } + + function test_delete_non_compact_option_returns_true_when_successfully_deleted() { + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_options', array() ), + 'return' => array(), + ) ); + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options', array() ), + 'return' => array(), + ) ); + \WP_Mock::userFunction( 'delete_option', array( + 'times' => 1, + 'args' => array( 'jetpack_uncompact_option_name' ), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => false, + ) ); + + $deleted = $this->manager->delete_option( 'uncompact_option_name' ); + + // Did Jetpack_Options::delete_option() properly return true? + $this->assertTrue( $deleted ); + } + + function test_delete_network_option_returns_true_when_successfully_deleted() { + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_options', array() ), + 'return' => array(), + ) ); + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options', array() ), + 'return' => array(), + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'delete_site_option', array( + 'times' => 1, + 'args' => array( 'jetpack_network_name' ), + 'return' => true, + ) ); + + $deleted = $this->manager->delete_option( 'network_name' ); + + // Did Jetpack_Options::delete_option() properly return true? + $this->assertTrue( $deleted ); + } + + function test_delete_private_option_returns_true_when_successfully_deleted() { + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_options', array() ), + 'return' => array(), + ) ); + \WP_Mock::userFunction( 'get_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options', array() ), + 'return' => array( 'private_name' => false ), + ) ); + \WP_Mock::userFunction( 'update_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options', array() ), + 'return' => true, + ) ); + + $deleted = $this->manager->delete_option( 'private_name' ); + + // Did Jetpack_Options::delete_option() properly return true? + $this->assertTrue( $deleted ); + } + + function test_update_non_compact_option_returns_true_when_successfully_updated() { + \WP_Mock::expectAction( + 'pre_update_jetpack_option_uncompact_option_name', + 'uncompact_option_name', + true + ); + + \WP_Mock::userFunction( 'update_option', array( + 'times' => 1, + 'args' => array( 'jetpack_uncompact_option_name', true, NULL ), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => false, + ) ); + + $updated = $this->manager->update_option( 'uncompact_option_name', true ); + + // Did Jetpack_Options::delete_option() properly return true? + $this->assertTrue( $updated ); + } + + function test_update_network_option_returns_true_when_successfully_updated() { + \WP_Mock::expectAction( + 'pre_update_jetpack_option_network_name', + 'network_name', + true + ); + + \WP_Mock::userFunction( 'update_site_option', array( + 'times' => 1, + 'args' => array( 'jetpack_network_name', true ), + 'return' => true, + ) ); + \WP_Mock::userFunction( 'is_multisite', array( + 'times' => 1, + 'args' => array(), + 'return' => true, + ) ); + + $updated = $this->manager->update_option( 'network_name', true ); + + // Did Jetpack_Options::update_option() properly return true? + $this->assertTrue( $updated ); + } + + function test_update_private_option_returns_true_when_successfully_updated() { + \WP_Mock::expectAction( + 'pre_update_jetpack_option_private_name', + 'private_name', + true + ); + + \WP_Mock::userFunction( 'update_option', array( + 'times' => 1, + 'args' => array( 'jetpack_private_options', array( 'private_name' => true ) ), + 'return' => true, + ) ); + + $updated = $this->manager->update_option( 'private_name', true ); + + // Did Jetpack_Options::update_option() properly return true? + $this->assertTrue( $updated ); + } + + public function tearDown() { + \WP_Mock::tearDown(); + } +} + +class Manager_Test extends Manager { + + /** + * Returns an array of option names for a given type. + * + * @param string $type The type of option to return. Defaults to 'compact'. + * + * @return array + */ + public function get_option_names( $type = 'compact' ) { + switch ( $type ) { + case 'non-compact' : + case 'non_compact' : + return array( + 'network_name', + 'uncompact_option_name', + ); + + case 'private' : + return array( + 'private_name' + ); + + case 'network' : + return array( + 'network_name' // Network options must be listed a second time + ); + } + + return array( + 'id' + ); + } +} diff --git a/packages/options/tests/bootstrap.php b/packages/options/tests/bootstrap.php new file mode 100644 index 000000000000..d914bd03446f --- /dev/null +++ b/packages/options/tests/bootstrap.php @@ -0,0 +1,5 @@ +