diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a9f738e..63b695c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,7 +34,7 @@ jobs: - uses: technote-space/get-diff-action@v6 # repo is archived. with: - SUFFIX_FILTER: .php + PATTERNS: ./**/*.php - name: Get Composer cache directory id: composer-cache @@ -68,5 +68,5 @@ jobs: GITHUB_TOKEN: "${{ github.token }}" - name: Detecting PHP Code Standards Violations - run: vendor/bin/phpcs --standard=phpcs.xml -s ${{ env.GIT_DIFF }} --report=checkstyle | cs2pr - if: "!! env.GIT_DIFF" + run: vendor/bin/phpcs --standard=phpcs.xml -s ${{ env.GIT_DIFF_FILTERED }} --report=checkstyle | cs2pr + if: "!! env.GIT_DIFF_FILTERED" diff --git a/.github/workflows/unit-tests-and-coverage-report.yml b/.github/workflows/unit-tests-and-coverage-report.yml index ef254c0..66493ff 100644 --- a/.github/workflows/unit-tests-and-coverage-report.yml +++ b/.github/workflows/unit-tests-and-coverage-report.yml @@ -15,7 +15,7 @@ on: jobs: - codecoverage-main: + unit-tests: runs-on: ubuntu-latest services: diff --git a/composer.json b/composer.json index a5d8d2a..84255c0 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "vendor/bin/phpcbf . --standard=phpcs.xml" ], "cs-changes": [ - "updated_files=$( git status | grep '\\(new file\\|modified\\):\\s.*.php$' | cut -c14- | awk '{ printf(\"%s \", $0) }' ); echo \"\\nChecking\"$(git status | grep '\\(new file\\|modified\\):\\s.*.php$' | tail -n+2 | wc -l)\" files\"; phpcbf $(echo $updated_files); phpcs $(echo $updated_files);" + "updated_files=$(echo $(git diff --name-only `git merge-base origin/main HEAD` | grep php)); if [ -n \"$updated_files\" ]; then phpcbf $(echo $updated_files); phpcs $(echo $updated_files); else echo \"No modified .php files for PHPCS.\"; fi;" ], "lint": [ "vendor/bin/phpcs . --standard=phpcs.xml -s" @@ -121,6 +121,7 @@ "lucatume/wp-browser": "^3.5.8", "newfold-labs/wp-php-standards": "^1.2.3", "phpunit/phpcov": "^5.0", + "wpackagist-plugin/jetpack": "^14.0", "wpackagist-plugin/woocommerce": ">=9" }, "extra": { diff --git a/composer.lock b/composer.lock index 9d16ab6..88be259 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bbcb858d87850fac8c58931c178bed8", + "content-hash": "7bf05ebfcacc11c210f05f49d53be012", "packages": [ { "name": "doctrine/inflector", @@ -6123,6 +6123,24 @@ ], "time": "2024-03-25T16:39:00+00:00" }, + { + "name": "wpackagist-plugin/jetpack", + "version": "14.0", + "source": { + "type": "svn", + "url": "https://plugins.svn.wordpress.org/jetpack/", + "reference": "tags/14.0" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/plugin/jetpack.14.0.zip" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + }, + "type": "wordpress-plugin", + "homepage": "https://wordpress.org/plugins/jetpack/" + }, { "name": "wpackagist-plugin/woocommerce", "version": "9.0.0", diff --git a/includes/Helpers/Plugin.php b/includes/Helpers/Plugin.php index 5d8855a..9d3643a 100644 --- a/includes/Helpers/Plugin.php +++ b/includes/Helpers/Plugin.php @@ -4,6 +4,8 @@ /** * Helper class for gathering and formatting plugin data + * + * @phpstan-type plugin-array array{slug:string, version:string, title:string, url:string, active:bool, mu:bool, auto_updates:bool, users?:array} */ class Plugin { /** @@ -11,23 +13,23 @@ class Plugin { * * @param string $basename The plugin basename (filename relative to WP_PLUGINS_DIR). * - * @return array{slug:string, version:string, title:string, url:string, active:bool, mu:bool, auto_updates:bool} Hiive relevant plugin details + * @return plugin-array Hiive relevant plugin details */ - public static function collect( $basename ) { + public function collect( $basename ): array { if ( ! function_exists( 'get_plugin_data' ) ) { require wp_normalize_path( constant( 'ABSPATH' ) . '/wp-admin/includes/plugin.php' ); } - return self::get_data( $basename, get_plugin_data( constant( 'WP_PLUGIN_DIR' ) . '/' . $basename ) ); + return $this->get_data( $basename, get_plugin_data( constant( 'WP_PLUGIN_DIR' ) . '/' . $basename ) ); } /** * Prepare plugin data for all plugins * - * @return array of plugins + * @return array of plugins */ - public static function collect_installed() { + public function collect_installed(): array { if ( ! function_exists( 'get_plugins' ) ) { require wp_normalize_path( constant( 'ABSPATH' ) . '/wp-admin/includes/plugin.php' ); } @@ -36,12 +38,12 @@ public static function collect_installed() { // Collect standard plugins foreach ( get_plugins() as $slug => $data ) { - array_push( $plugins, self::get_data( $slug, $data ) ); + array_push( $plugins, $this->get_data( $slug, $data ) ); } // Collect mu plugins foreach ( get_mu_plugins() as $slug => $data ) { - array_push( $plugins, self::get_data( $slug, $data, true ) ); + array_push( $plugins, $this->get_data( $slug, $data, true ) ); } return $plugins; @@ -54,17 +56,21 @@ public static function collect_installed() { * @param array $data The plugin meta-data from its header. * @param bool $mu Whether the plugin is installed as a must-use plugin. * - * @return array{slug:string, version:string, title:string, url:string, active:bool, mu:bool, auto_updates:bool} Hiive relevant plugin details + * @return plugin-array Hiive relevant plugin details */ - public static function get_data( $basename, $data, $mu = false ) { + public function get_data( string $basename, array $data, bool $mu = false ): array { $plugin = array(); $plugin['slug'] = $basename; - $plugin['version'] = $data['Version'] ? $data['Version'] : '0.0'; - $plugin['title'] = $data['Name'] ? $data['Name'] : ''; - $plugin['url'] = $data['PluginURI'] ? $data['PluginURI'] : ''; + $plugin['version'] = isset( $data['Version'] ) ? $data['Version'] : '0.0'; + $plugin['title'] = isset( $data['Name'] ) ? $data['Name'] : ''; + $plugin['url'] = isset( $data['PluginURI'] ) ? $data['PluginURI'] : ''; $plugin['active'] = is_plugin_active( $basename ); $plugin['mu'] = $mu; - $plugin['auto_updates'] = ( ! $mu && self::does_it_autoupdate( $basename ) ); + $plugin['auto_updates'] = ( ! $mu && $this->does_it_autoupdate( $basename ) ); + + if ( strpos( $basename, 'jetpack/jetpack.php' ) !== false ) { + $plugin['users'] = $this->get_admin_users(); + } return $plugin; } @@ -73,12 +79,10 @@ public static function get_data( $basename, $data, $mu = false ) { * Whether the plugin is set to auto update * * @param string $slug Name of the plugin - * - * @return bool */ - public static function does_it_autoupdate( $slug ) { + protected function does_it_autoupdate( string $slug ): bool { // Check plugin setting for auto updates on all plugins - if ( get_site_option( 'auto_update_plugin', 'true' ) ) { + if ( 'true' === get_site_option( 'auto_update_plugin', 'true' ) ) { return true; } @@ -87,4 +91,29 @@ public static function does_it_autoupdate( $slug ) { return in_array( $slug, $wp_auto_updates, true ); } + + /** + * Get Admin and SuperAdmin user accounts + * + * @return array $users Array of Admin & Super Admin users + */ + protected function get_admin_users(): array { + // Get all admin users + $admin_users = get_users( + array( + 'role' => 'administrator', + ) + ); + $users = array(); + + // Add administrators to the $users and check for super admin + foreach ( $admin_users as $user ) { + $users[] = array( + 'id' => $user->ID, + 'email' => $user->user_email, + ); + } + + return $users; + } } diff --git a/includes/HiiveConnection.php b/includes/HiiveConnection.php index f9a7a77..abb2ecb 100644 --- a/includes/HiiveConnection.php +++ b/includes/HiiveConnection.php @@ -78,17 +78,23 @@ public function rest_api_init(): void { * * Hiive will first attempt to verify using the REST API, and fallback to this AJAX endpoint on error. * + * Token is generated in {@see self::connect()} using {@see md5()}. + * * @hooked wp_ajax_nopriv_nfd-hiive-verify * * @return never */ public function ajax_verify() { - $valid = $this->verify_token( $_REQUEST['token'] ); - $status = ( $valid ) ? 200 : 400; + // PHPCS: Ignore the nonce verification here – the token _is_ a nonce. + // @phpcs:ignore WordPress.Security.NonceVerification.Recommended + $token = $_REQUEST['token']; + + $is_valid = $this->verify_token( $token ); + $status = ( $is_valid ) ? 200 : 400; $data = array( - 'token' => $_REQUEST['token'], - 'valid' => $valid, + 'token' => $token, + 'valid' => $is_valid, ); \wp_send_json( $data, $status ); } @@ -96,6 +102,8 @@ public function ajax_verify() { /** * Confirm whether verification token is valid * + * Token is generated in {@see self::connect()} using {@see md5()}. + * * @param string $token Token to verify */ public function verify_token( string $token ): bool { @@ -144,7 +152,7 @@ public function connect( string $path = '/sites/v2/connect', ?string $authorizat $data = $this->get_core_data(); $data['verify_token'] = $token; - $data['plugins'] = PluginHelper::collect_installed(); + $data['plugins'] = ( new PluginHelper() )->collect_installed(); $args = array( 'body' => \wp_json_encode( $data ), @@ -260,7 +268,6 @@ public function send_event( Event $event ) { $hiive_response = $this->hiive_request( 'sites/v1/events', $payload ); if ( is_wp_error( $hiive_response ) ) { - // TODO: enqueue failed event for later. Should this function call go via EventManager? return $hiive_response; } diff --git a/includes/Listeners/Cron.php b/includes/Listeners/Cron.php index ba8bc47..ddc1bd1 100644 --- a/includes/Listeners/Cron.php +++ b/includes/Listeners/Cron.php @@ -43,7 +43,7 @@ public function register_hooks(): void { */ public function update(): void { $data = array( - 'plugins' => Plugin::collect_installed(), + 'plugins' => ( new Plugin() )->collect_installed(), ); $data = apply_filters( 'newfold_wp_data_module_cron_data_filter', $data ); diff --git a/includes/Listeners/Plugin.php b/includes/Listeners/Plugin.php index d74534c..1bbc4c4 100644 --- a/includes/Listeners/Plugin.php +++ b/includes/Listeners/Plugin.php @@ -2,6 +2,7 @@ namespace NewfoldLabs\WP\Module\Data\Listeners; +use NewfoldLabs\WP\Module\Data\EventManager; use NewfoldLabs\WP\Module\Data\Helpers\Transient; use NewfoldLabs\WP\Module\Data\Helpers\Plugin as PluginHelper; @@ -9,6 +10,28 @@ * Monitors generic plugin events */ class Plugin extends Listener { + + /** + * Functions for gathering plugin data + * + * @var PluginHelper + */ + protected $plugin_helper; + + /** + * Constructor + * + * @param EventManager $manager Event manager instance + * @param ?PluginHelper $plugin_helper Class used to fetch plugin data. + */ + public function __construct( + EventManager $manager, + ?PluginHelper $plugin_helper = null + ) { + parent::__construct( $manager ); + $this->plugin_helper = $plugin_helper ?? new PluginHelper(); + } + /** * Register the hooks for the subscriber * @@ -42,7 +65,7 @@ public function register_hooks() { */ public function activated( $plugin, $network_wide ) { $data = array( - 'plugin' => PluginHelper::collect( $plugin ), + 'plugin' => $this->plugin_helper->collect( $plugin ), 'network_wide' => $network_wide, ); $this->push( 'plugin_activated', $data ); @@ -58,7 +81,7 @@ public function activated( $plugin, $network_wide ) { */ public function deactivated( $plugin, $network_wide ) { $data = array( - 'plugin' => PluginHelper::collect( $plugin ), + 'plugin' => $this->plugin_helper->collect( $plugin ), 'network_wide' => $network_wide, ); @@ -76,7 +99,7 @@ public function deactivated( $plugin, $network_wide ) { * @return void */ public function save_deleted( $plugin ) { - update_option( 'deleted_plugin', PluginHelper::collect( $plugin ) ); + update_option( 'deleted_plugin', $this->plugin_helper->collect( $plugin ) ); } /** @@ -137,12 +160,12 @@ protected function updated( array $options ): void { // Manual updates always return array of plugin slugs if ( isset( $options['plugins'] ) && is_array( $options['plugins'] ) ) { foreach ( $options['plugins'] as $slug ) { - array_push( $plugins, PluginHelper::collect( $slug ) ); + array_push( $plugins, $this->plugin_helper->collect( $slug ) ); } } // Auto updates always return a single plugin slug if ( isset( $options['plugin'] ) ) { - array_push( $plugins, PluginHelper::collect( $options['plugin'] ) ); + array_push( $plugins, $this->plugin_helper->collect( $options['plugin'] ) ); } $data = array( @@ -159,7 +182,7 @@ protected function updated( array $options ): void { */ public function installed() { $data = array( - 'plugins' => PluginHelper::collect_installed(), + 'plugins' => $this->plugin_helper->collect_installed(), ); $this->push( 'plugin_installed', $data ); } diff --git a/patchwork.json b/patchwork.json index 6a91263..f7e6eb4 100644 --- a/patchwork.json +++ b/patchwork.json @@ -3,6 +3,7 @@ "defined", "constant", "file_get_contents", + "function_exists", "time" ] } diff --git a/phpcs.xml b/phpcs.xml index 1c6f8e2..8d5883c 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -7,4 +7,7 @@ includes upgrades + + + diff --git a/tests/phpunit/includes/Helpers/PluginTest.php b/tests/phpunit/includes/Helpers/PluginTest.php new file mode 100644 index 0000000..3da9512 --- /dev/null +++ b/tests/phpunit/includes/Helpers/PluginTest.php @@ -0,0 +1,78 @@ +once() + ->andReturn( array( + + ) ); + WP_Mock::userFunction( 'is_plugin_active' ) + ->once() + ->andReturnTrue(); + + WP_Mock::userFunction( 'get_site_option' ) + ->once() + ->with('auto_update_plugin', 'true') + ->andReturnTrue(); + + WP_Mock::userFunction( 'get_site_option' ) + ->once() + ->with('auto_update_plugins', \WP_Mock\Functions::type( 'array' )) + ->andReturn(array()); + + $sut->collect( 'bluehost-wordpress-plugin/bluehost-wordpress-plugin.php' ); + + $this->assertConditionsMet(); + } +} diff --git a/tests/phpunit/includes/Listeners/PluginTest.php b/tests/phpunit/includes/Listeners/PluginTest.php index 95f5599..cabe175 100644 --- a/tests/phpunit/includes/Listeners/PluginTest.php +++ b/tests/phpunit/includes/Listeners/PluginTest.php @@ -4,6 +4,7 @@ use Mockery; use NewfoldLabs\WP\Module\Data\EventManager; +use NewfoldLabs\WP\Module\Data\Helpers\Plugin as Plugin_Helper; use WP_Mock; /** @@ -17,8 +18,6 @@ class PluginTest extends \WP_Mock\Tools\TestCase { public function tearDown(): void { parent::tearDown(); - \Patchwork\restoreAll(); - unset( $_SERVER['REMOTE_ADDR'] ); unset( $_SERVER['REQUEST_URI'] ); } @@ -33,16 +32,18 @@ public function tearDown(): void { public function upgrader_process_complete_data_provider(): array { return array( array( - 'plugins' => array( + 'plugins' => array( 'bluehost-wordpress-plugin/bluehost-wordpress-plugin.php', ), - 'expect_push_times' => 1, + 'expected_count_collect' => 1, + 'expect_push_times' => 1, ), array( - 'plugins' => array( + 'plugins' => array( '', ), - 'expect_push_times' => 0, + 'expected_count_collect' => 0, + 'expect_push_times' => 0, ), ); } @@ -63,7 +64,7 @@ public function upgrader_process_complete_data_provider(): array { * @param array $plugins The plugins value sent to the `upgrader_process_complete` action. * @param int $expect_push_times The number of times the `push` method should be called. I.e. 0 when there is no plugin. */ - public function test_upgrader_process_complete_fired( array $plugins, int $expect_push_times ): void { + public function test_upgrader_process_complete_fired( array $plugins, int $expected_count_collect, int $expect_push_times ): void { /** * It is difficult to mock the `Plugin_Upgrader` class, so we will just pass `null` for now. @@ -79,10 +80,8 @@ public function test_upgrader_process_complete_fired( array $plugins, int $expec 'plugins' => $plugins, ); - $event_manager = Mockery::mock( EventManager::class ); - $event_manager->expects( 'push' )->times( $expect_push_times ); - - $sut = new Plugin( $event_manager ); + $event_manager_mock = Mockery::mock( EventManager::class ); + $event_manager_mock->expects( 'push' )->times( $expect_push_times ); /** * This will only be called if the plugin is not empty, meaning we don't test with the current problematic @@ -100,13 +99,13 @@ public function test_upgrader_process_complete_fired( array $plugins, int $expec 'auto_updates' => true, ); - \Patchwork\redefine( - array( \NewfoldLabs\WP\Module\Data\Helpers\Plugin::class, 'collect' ), - function () use ( $plugin_collected ) { - return $plugin_collected; - } - ); + $plugin_helper_mock = Mockery::mock( Plugin_Helper::class )->makePartial(); + $plugin_helper_mock->shouldReceive( 'collect' ) + ->times( $expected_count_collect ) + ->with( 'bluehost-wordpress-plugin/bluehost-wordpress-plugin.php' ) + ->andReturn( $plugin_collected ); + $sut = new Plugin( $event_manager_mock, $plugin_helper_mock ); /** * The Event constructor calls a lot of WordPress functions to determine the environment. * diff --git a/tests/wpunit.suite.yml b/tests/wpunit.suite.yml index b86627a..be14fef 100644 --- a/tests/wpunit.suite.yml +++ b/tests/wpunit.suite.yml @@ -19,4 +19,5 @@ modules: activatePlugins: [] WP_HTTP_BLOCK_EXTERNAL: true + WP_CONTENT_DIR: "wp-content" bootstrap: _bootstrap.php diff --git a/tests/wpunit/includes/Helpers/PluginWPUnitTest.php b/tests/wpunit/includes/Helpers/PluginWPUnitTest.php new file mode 100644 index 0000000..2a520eb --- /dev/null +++ b/tests/wpunit/includes/Helpers/PluginWPUnitTest.php @@ -0,0 +1,118 @@ +collect('bluehost-wordpress-plugin/bluehost-wordpress-plugin.php'); + + $this->assertEquals('The Bluehost Plugin', $result['title']); + $this->assertEqualSets(['slug','version','title','url','active','mu','auto_updates'], array_keys($result)); + } + + /** + * @covers ::collect + * @covers ::get_data + * @covers ::get_admin_users + */ + public function test_collect_jetpack(): void { + $new_user_id = wp_create_user('admin2', 'password', 'email@example.com'); + $new_user = new \WP_User($new_user_id); + $new_user->add_role('administrator'); + + $sut = new Plugin(); + + $result = $sut->collect('jetpack/jetpack.php'); + + $this->assertEqualSets(['slug','version','title','url','active','mu','auto_updates','users',], array_keys($result)); + + $this->assertCount(2, $result['users']); + + $this->assertEqualSets(['id','email',], array_keys($result['users'][1])); + } + + /** + * @covers ::collect_installed + */ + public function test_collect_installed(): void { + $sut = new Plugin(); + + $result = $sut->collect_installed(); + + $this->assertGreaterThan(2, count($result)); + } + + /** + * @see wp_ajax_toggle_auto_updates() + */ + protected function set_autoupdate(string $basename, bool $enabled): void { + + $state = $enabled ? 'enable' : 'disable'; + + $auto_updates = (array) get_site_option( 'auto_update_plugins', array() ); + $all_plugins = get_plugins(); + + if ( 'disable' === $state ) { + $auto_updates = array_diff( $auto_updates, array( $basename ) ); + } else { + $auto_updates[] = $basename; + $auto_updates = array_unique( $auto_updates ); + } + + $auto_updates = array_intersect( $auto_updates, array_keys( $all_plugins ) ); + + update_site_option( 'auto_update_plugins', $auto_updates ); + } + + /** + * @covers ::does_it_autoupdate + */ + public function test_does_it_autoupdate(): void { + $sut = new Plugin(); + + add_site_option('auto_update_plugin', 'false'); + + $this->set_autoupdate('bluehost-wordpress-plugin/bluehost-wordpress-plugin.php', true); + $this->set_autoupdate('jetpack/jetpack.php', false); + + $this->assertTrue($sut->collect('bluehost-wordpress-plugin/bluehost-wordpress-plugin.php')['auto_updates']); + $this->assertFalse($sut->collect('jetpack/jetpack.php')['auto_updates']); + } + + /** + * @covers ::does_it_autoupdate + */ + public function test_does_it_autoupdate_all(): void { + $sut = new Plugin(); + + add_site_option('auto_update_plugin', 'true'); + + $this->set_autoupdate('bluehost-wordpress-plugin/bluehost-wordpress-plugin.php', false); + + $this->assertTrue($sut->collect('bluehost-wordpress-plugin/bluehost-wordpress-plugin.php')['auto_updates']); + } +}