diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index c9d790705..a2b793417 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -9,12 +9,27 @@ env: jobs: lint: - name: Lint and Test + name: Lint and Test (PHP ${{ matrix.php }}) runs-on: ubuntu-22.04 - timeout-minutes: 10 + timeout-minutes: 20 permissions: contents: read packages: read + strategy: + fail-fast: false + matrix: + include: + - php: '7.4' + wordpress_image: latest + xdebug: '3.1.6' + - php: '8.2' + wordpress_image: php8.2 + xdebug: '3.2.1' + - php: '8.3' + wordpress_image: php8.3 + xdebug: '3.3.2' + env: + WORDPRESS_IMAGE_VERSION: ${{ matrix.wordpress_image }} steps: - name: Checkout @@ -40,8 +55,8 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-php-${{ hashFiles( 'composer.lock' ) }} - restore-keys: ${{ runner.os }}-php- + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles( 'composer.lock' ) }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- - name: Log in to the Container registry uses: docker/login-action@v3 @@ -63,19 +78,43 @@ jobs: run: npm run build - name: Pull custom Docker images - run: docker compose pull wordpress + # `--ignore-pull-failures` so unpublished tags (e.g. php8.3 before + # docker-images.yml has published it from master) still pass. Compose + # then builds from local/docker/wordpress/Dockerfile. + run: docker compose pull --ignore-pull-failures wordpress + + - name: Build WordPress image for matrix PHP + run: docker compose build --build-arg PHP_VERSION=${{ matrix.php }} --build-arg XDEBUG_VERSION=${{ matrix.xdebug }} wordpress + + - name: Confirm container PHP version + run: npm run which-php - name: Test run: npm run test e2e: - name: E2E + name: E2E (PHP ${{ matrix.php }}) runs-on: ubuntu-22.04 - timeout-minutes: 12 + timeout-minutes: 20 needs: lint permissions: contents: read packages: read + strategy: + fail-fast: false + matrix: + include: + - php: '7.4' + wordpress_image: latest + xdebug: '3.1.6' + - php: '8.2' + wordpress_image: php8.2 + xdebug: '3.2.1' + - php: '8.3' + wordpress_image: php8.3 + xdebug: '3.3.2' + env: + WORDPRESS_IMAGE_VERSION: ${{ matrix.wordpress_image }} steps: - name: Checkout @@ -101,8 +140,8 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-php-${{ hashFiles( 'composer.lock' ) }} - restore-keys: ${{ runner.os }}-php- + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles( 'composer.lock' ) }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- - name: Map stream.wpenv.net to localhost run: echo "127.0.0.1 stream.wpenv.net" | sudo tee -a /etc/hosts @@ -150,11 +189,16 @@ jobs: # :443 with ERR_CONNECTION_CLOSED. Build locally so the branch's # Dockerfile actually applies; Docker layer caching keeps this cheap # when the Dockerfile hasn't changed since the last published build. - run: docker compose build wordpress + # PHP_VERSION / XDEBUG_VERSION match docker-compose.build.yml so + # unpublished tags (php8.3) still produce the matrix PHP runtime. + run: docker compose build --build-arg PHP_VERSION=${{ matrix.php }} --build-arg XDEBUG_VERSION=${{ matrix.xdebug }} wordpress - name: Start WordPress run: npm run start + - name: Confirm container PHP version + run: npm run which-php + - name: Wait for MySQL (from inside the wordpress container) run: | npm run cli -- php -r ' @@ -188,6 +232,6 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-php-${{ matrix.php }} path: playwright-report retention-days: 7 diff --git a/classes/class-as-scheduler.php b/classes/class-as-scheduler.php index ff54273ae..1daa17da4 100644 --- a/classes/class-as-scheduler.php +++ b/classes/class-as-scheduler.php @@ -35,7 +35,9 @@ class AS_Scheduler implements Scheduler { * @return void */ public function enqueue_async( $hook, $args = array(), $group = '' ) { - as_enqueue_async_action( $hook, $args, $group ); + if ( function_exists( 'as_enqueue_async_action' ) ) { + as_enqueue_async_action( $hook, $args, $group ); + } } /** @@ -54,6 +56,10 @@ public function enqueue_async( $hook, $args = array(), $group = '' ) { * @return void */ public function schedule_recurring( $timestamp, $interval, $hook, $args = array(), $group = '' ) { + if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) { + return; + } + if ( false === as_next_scheduled_action( $hook ) ) { as_schedule_recurring_action( $timestamp, $interval, $hook, $args, $group ); } @@ -67,6 +73,10 @@ public function schedule_recurring( $timestamp, $interval, $hook, $args = array( * @return int|false */ public function next_scheduled( $hook, $args = array() ) { + if ( ! function_exists( 'as_next_scheduled_action' ) ) { + return false; + } + return as_next_scheduled_action( $hook, empty( $args ) ? null : $args ); } @@ -77,6 +87,10 @@ public function next_scheduled( $hook, $args = array() ) { * @return bool */ public function has_scheduled( $hook ) { + if ( ! function_exists( 'as_has_scheduled_action' ) ) { + return false; + } + return as_has_scheduled_action( $hook ); } diff --git a/classes/class-install.php b/classes/class-install.php index cdad2b6cb..af6bb11d9 100644 --- a/classes/class-install.php +++ b/classes/class-install.php @@ -345,7 +345,7 @@ public function update( $db_version, $current_version, $update_args ) { $function = 'wp_stream_update_' . ( 'user' === $update_args['type'] ? '' : $update_args['type'] . '_' ) . str_ireplace( '.', '', $version ); if ( version_compare( $db_version, $version, '<' ) ) { - $result = function_exists( $function ) ? call_user_func( $function, $db_version, $current_version ) : $current_version; + $result = function_exists( $function ) ? call_user_func( $function, $db_version, $current_version, $this ) : $current_version; if ( $current_version !== $result ) { return false; diff --git a/connectors/class-connector-two-factor.php b/connectors/class-connector-two-factor.php index 91c455c01..01cb2418a 100644 --- a/connectors/class-connector-two-factor.php +++ b/connectors/class-connector-two-factor.php @@ -334,6 +334,10 @@ public function callback_wp_login_failed( $user_login, $error ) { $user = get_user_by( 'email', $user_login ); } + if ( ! $user ) { + return; + } + /* Translators: %1$s is the user display name, %2$s is the error code, %3$s is the error message. */ $message = __( '%1$s Failed 2FA: %2$s %3$s', diff --git a/contributing.md b/contributing.md index a401a4022..18f26c40c 100644 --- a/contributing.md +++ b/contributing.md @@ -106,7 +106,7 @@ We use npm as the canonical task runner for the project. The following commands - `npm run test-xdebug` will run the PHPunit tests with Xdebug enabled. - `npm run test-e2e` will run the Playwright E2E tests. - `npm run test-e2e-debug` will run the Playwright E2E tests in a debug mode (with Chromium browser and dev tools open). -- `npm run switch-to:php7.4` and `npm run switch-to:php8.2` will switch you to either PHP 7.4 or PHP 8.2 +- `npm run switch-to:php7.4`, `npm run switch-to:php8.2`, and `npm run switch-to:php8.3` switch the WordPress container PHP version. The default remains PHP 7.4. Confirm with `npm run which-php`. - `npm run document:connectors` generates [connectors.md](connectors.md). This runs via your local php. - `npm run large-records-generate` inserts ~1.6M rows to `wp_stream` and ~8.4M rows to `wp_streammeta` for testing - `npm run large-records-remove` removes the test data only diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 9a475225b..c03e1e5fa 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -37,6 +37,14 @@ services: PHP_VERSION: "8.2" XDEBUG_VERSION: "3.2.1" + wordpress_php8.3: + image: ghcr.io/xwp/stream-wordpress:php8.3 + build: + context: ./local/docker/wordpress + args: + PHP_VERSION: "8.3" + XDEBUG_VERSION: "3.3.2" + # Helper image that generates a locally-trusted TLS cert via mkcert for # the Apache HTTPS vhost on the wordpress service. Pre-built and pushed # to ghcr.io so CI and contributors don't rebuild it on every fresh diff --git a/includes/db-updates.php b/includes/db-updates.php index cd5d0c145..de9baf9f6 100644 --- a/includes/db-updates.php +++ b/includes/db-updates.php @@ -14,14 +14,18 @@ * * Force update for older versions to call \dbdelta in install() method to fix column widths. * - * @param string $db_version New database version. - * @param string $current_version Current database version. + * @param string $db_version New database version. + * @param string $current_version Current database version. + * @param \WP_Stream\Install|null $install Install instance performing the update. * - * @return string + * @return string|false */ -function wp_stream_update_auto_308( $db_version, $current_version ) { - $plugin = wp_stream_get_instance(); - $plugin->install->install( $current_version ); +function wp_stream_update_auto_308( $db_version, $current_version, $install = null ) { + if ( ! $install instanceof \WP_Stream\Install ) { + return false; + } + + $install->install( $current_version ); return $current_version; } @@ -72,19 +76,23 @@ function wp_stream_update_302( $db_version, $current_version ) { * * Update from 1.4.9 * - * @param string $db_version New database version. - * @param string $current_version Current database version. + * @param string $db_version New database version. + * @param string $current_version Current database version. + * @param \WP_Stream\Install|null $install Install instance performing the update. * - * @return string + * @return string|false */ -function wp_stream_update_auto_300( $db_version, $current_version ) { +function wp_stream_update_auto_300( $db_version, $current_version, $install = null ) { global $wpdb; // Get only the author_meta values that are double-serialized. $wpdb->query( "RENAME TABLE {$wpdb->base_prefix}stream TO {$wpdb->base_prefix}stream_tmp, {$wpdb->base_prefix}stream_context TO {$wpdb->base_prefix}stream_context_tmp" ); - $plugin = wp_stream_get_instance(); - $plugin->install->install( $current_version ); + if ( ! $install instanceof \WP_Stream\Install ) { + return false; + } + + $install->install( $current_version ); $starting_row = 0; $rows_per_round = 5000; diff --git a/package.json b/package.json index 985422d7e..8bf03a4e9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "push-containers": "docker compose --file docker-compose.build.yml push", "start": "docker compose up --remove-orphans --detach", "start-xdebug": "XDEBUG_TRIGGER=1 docker compose up --remove-orphans --detach", + "switch-to:php8.3": "docker compose build --build-arg PHP_VERSION=8.3 --build-arg XDEBUG_VERSION=3.3.2 && npm run start", "switch-to:php8.2": "docker compose build --build-arg PHP_VERSION=8.2 --build-arg XDEBUG_VERSION=3.3.2 && npm run start", "switch-to:php7.4": "docker compose build && npm run start", "which-php": "npm run cli -- php --version", diff --git a/stream.php b/stream.php index 3b644a88a..2a452ce62 100644 --- a/stream.php +++ b/stream.php @@ -72,8 +72,8 @@ function wp_stream_fail_php_version() { /** * Helper for external plugins which wish to use Stream. * - * @return WP_Stream\Plugin + * @return WP_Stream\Plugin|null Plugin instance, or null if Stream has not finished constructing. */ function wp_stream_get_instance() { - return $GLOBALS['wp_stream']; + return $GLOBALS['wp_stream'] ?? null; } diff --git a/tests/phpunit/connectors/test-class-connector-two-factor.php b/tests/phpunit/connectors/test-class-connector-two-factor.php index b167baee7..43dcacb4b 100644 --- a/tests/phpunit/connectors/test-class-connector-two-factor.php +++ b/tests/phpunit/connectors/test-class-connector-two-factor.php @@ -153,4 +153,150 @@ public function test_callback_updated_user_meta() { \Two_Factor_Core::disable_provider_for_user( $this->user_id, 'Two_Factor_Dummy' ); } + + /** + * Older Two Factor fires this action with only the user argument. + */ + public function test_callback_two_factor_user_authenticated_without_provider_logs_unknown_method() { + // Arrange + wp_set_current_user( $this->user_id ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->with( + $this->equalTo( + __( + 'Authenticated via %s', + 'stream' + ) + ), + $this->equalTo( + array( + 'provider' => __( 'unknown Two Factor method', 'stream' ), + ) + ), + $this->user_id, + 'auth', + 'authenticated', + $this->user_id + ); + + // Act + do_action( 'two_factor_user_authenticated', $this->user ); + } + + /** + * Failed 2FA for a known login is logged against that user. + */ + public function test_callback_wp_login_failed_known_user_logs_failed_2fa() { + // Arrange + $error = new \WP_Error( + 'two_factor_invalid', + 'ERROR: Invalid verification code.' + ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->with( + $this->equalTo( + __( + '%1$s Failed 2FA: %2$s %3$s', + 'stream' + ) + ), + $this->equalTo( + array( + 'display_name' => $this->user->display_name, + 'code' => 'two_factor_invalid', + 'error' => 'ERROR: Invalid verification code.', + ) + ), + $this->user_id, + 'auth', + 'failed', + $this->user_id + ); + + // Act + $this->mock->callback_wp_login_failed( $this->user->user_login, $error ); + } + + /** + * Failed 2FA submitted as an email still resolves the user. + */ + public function test_callback_wp_login_failed_email_login_logs_failed_2fa() { + // Arrange + $email = 'twofactor-login@example.com'; + $user_id = self::factory()->user->create( + array( + 'user_email' => $email, + 'display_name' => 'emailuserdisplay', + ) + ); + $user = get_user_by( 'id', $user_id ); + + $error = new \WP_Error( + 'two_factor_invalid', + 'ERROR: Invalid verification code.' + ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->with( + $this->equalTo( + __( + '%1$s Failed 2FA: %2$s %3$s', + 'stream' + ) + ), + $this->equalTo( + array( + 'display_name' => $user->display_name, + 'code' => 'two_factor_invalid', + 'error' => 'ERROR: Invalid verification code.', + ) + ), + $user_id, + 'auth', + 'failed', + $user_id + ); + + // Act + $this->mock->callback_wp_login_failed( $email, $error ); + } + + /** + * Unknown login must not dereference a false $user (PHP 8 property-on-bool warning). + */ + public function test_callback_wp_login_failed_unknown_user_does_not_log() { + // Arrange + $error = new \WP_Error( + 'two_factor_invalid', + 'ERROR: Invalid verification code.' + ); + + $this->mock->expects( $this->never() ) + ->method( 'log' ); + + // Act + $this->mock->callback_wp_login_failed( 'does-not-exist-xyz', $error ); + } + + /** + * Non-Two-Factor login failures are ignored by this connector. + */ + public function test_callback_wp_login_failed_non_two_factor_error_does_not_log() { + // Arrange + $error = new \WP_Error( + 'incorrect_password', + 'The password you entered is incorrect.' + ); + + $this->mock->expects( $this->never() ) + ->method( 'log' ); + + // Act + $this->mock->callback_wp_login_failed( $this->user->user_login, $error ); + } } diff --git a/tests/phpunit/test-class-as-scheduler.php b/tests/phpunit/test-class-as-scheduler.php new file mode 100644 index 000000000..b278fb436 --- /dev/null +++ b/tests/phpunit/test-class-as-scheduler.php @@ -0,0 +1,113 @@ +markTestSkipped( 'Action Scheduler is not loaded in this environment.' ); + } + + $this->scheduler = new AS_Scheduler(); + $this->clear_test_hooks(); + } + + public function tearDown(): void { + $this->clear_test_hooks(); + parent::tearDown(); + } + + /** + * Drop any actions these tests may have scheduled. + */ + protected function clear_test_hooks() { + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( $this->test_hook ); + as_unschedule_all_actions( Admin::AUTO_PURGE_ACTION ); + } + } + + /** + * Enqueue_async() schedules a one-off action detectable by has_scheduled(). + */ + public function test_enqueue_async_schedules_action() { + $this->assertFalse( $this->scheduler->has_scheduled( $this->test_hook ) ); + + $this->scheduler->enqueue_async( + $this->test_hook, + array( + 'a' => 1, + 'b' => 2, + ) + ); + + $this->assertTrue( $this->scheduler->has_scheduled( $this->test_hook ) ); + $this->assertNotFalse( + $this->scheduler->next_scheduled( + $this->test_hook, + array( + 'a' => 1, + 'b' => 2, + ) + ) + ); + } + + /** + * Schedule_recurring() registers a recurring action and is idempotent. + */ + public function test_schedule_recurring_is_idempotent() { + $this->scheduler->schedule_recurring( time(), 12 * HOUR_IN_SECONDS, Admin::AUTO_PURGE_ACTION ); + $first = $this->scheduler->next_scheduled( Admin::AUTO_PURGE_ACTION ); + $this->assertNotFalse( $first ); + $this->assertTrue( $this->scheduler->has_scheduled( Admin::AUTO_PURGE_ACTION ) ); + + $this->scheduler->schedule_recurring( time() + 100, 12 * HOUR_IN_SECONDS, Admin::AUTO_PURGE_ACTION ); + $this->assertSame( $first, $this->scheduler->next_scheduled( Admin::AUTO_PURGE_ACTION ) ); + } + + /** + * Next_scheduled() returns false when nothing is queued for the hook. + */ + public function test_next_scheduled_returns_false_when_nothing_queued() { + $this->assertFalse( $this->scheduler->next_scheduled( $this->test_hook ) ); + $this->assertFalse( $this->scheduler->has_scheduled( $this->test_hook ) ); + } + + /** + * Unschedule_all() clears every pending instance of a hook. + */ + public function test_unschedule_all_clears_hook() { + $this->scheduler->enqueue_async( $this->test_hook, array( 1 ) ); + $this->assertTrue( $this->scheduler->has_scheduled( $this->test_hook ) ); + + $this->scheduler->unschedule_all( $this->test_hook ); + $this->assertFalse( $this->scheduler->has_scheduled( $this->test_hook ) ); + } +} diff --git a/tests/phpunit/test-class-install.php b/tests/phpunit/test-class-install.php new file mode 100644 index 000000000..68c6aeb1b --- /dev/null +++ b/tests/phpunit/test-class-install.php @@ -0,0 +1,108 @@ +install. + * + * @var Install|null + */ + protected $saved_install; + + public function setUp(): void { + parent::setUp(); + + $this->saved_install = $this->plugin->install; + } + + public function tearDown(): void { + $GLOBALS['wp_stream'] = $this->plugin; + + if ( $this->saved_install instanceof Install ) { + $this->plugin->install = $this->saved_install; + } + + update_site_option( 'wp_stream_db', Plugin::VERSION ); + + parent::tearDown(); + } + + /** + * Simulate Plugin::__construct() before $GLOBALS['wp_stream'] and $plugin->install are assigned. + */ + protected function simulate_construction_window() { + $this->plugin->install = null; + unset( $GLOBALS['wp_stream'] ); + } + + /** + * Leftover wp_stream_db < 3.0.8 must not fatal during Install construction. + */ + public function test_check_with_leftover_db_version_3_0_7_during_construction_does_not_fatal() { + update_site_option( 'wp_stream_db', '3.0.7' ); + $this->simulate_construction_window(); + + $install = new Install( $this->plugin ); + + $this->assertInstanceOf( Install::class, $install ); + $this->assertSame( Plugin::VERSION, get_site_option( 'wp_stream_db' ) ); + $this->assert_stream_schema_present(); + } + + /** + * Missing wp_stream_db during construction uses Install::install() (AC 9 clean install). + * + * An empty string option is the same empty() branch as a missing option. + */ + public function test_check_with_empty_db_version_during_construction_installs() { + delete_site_option( 'wp_stream_db' ); + $this->simulate_construction_window(); + + $install = new Install( $this->plugin ); + + $this->assertInstanceOf( Install::class, $install ); + $this->assertSame( Plugin::VERSION, get_site_option( 'wp_stream_db' ) ); + $this->assert_stream_schema_present(); + } + + /** + * Leftover 3.0.0 skips auto_300 (not < 3.0.0) and uses the same auto_308 dbDelta path as 3.0.7. + * + * Skipped: wp_stream_db < 3.0.0. wp_stream_update_auto_300() RENAME/DROPs stream and + * stream_context and would destroy the PHPUnit schema. + */ + public function test_check_with_leftover_db_version_3_0_0_during_construction_does_not_fatal() { + update_site_option( 'wp_stream_db', '3.0.0' ); + $this->simulate_construction_window(); + + $install = new Install( $this->plugin ); + + $this->assertInstanceOf( Install::class, $install ); + $this->assertSame( Plugin::VERSION, get_site_option( 'wp_stream_db' ) ); + $this->assert_stream_schema_present(); + } + + /** + * Stream tables exist and user_role is present (dbDelta / 3.0.8 column-width path). + */ + protected function assert_stream_schema_present() { + global $wpdb; + + $this->assertNotEmpty( $wpdb->stream ); + $this->assertNotEmpty( + $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->stream ) ) + ) + ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $columns = $wpdb->get_col( "DESCRIBE {$wpdb->stream}", 0 ); + $this->assertContains( 'user_role', $columns ); + } +} diff --git a/tests/phpunit/test-functions.php b/tests/phpunit/test-functions.php index 95748ba57..4232b6947 100644 --- a/tests/phpunit/test-functions.php +++ b/tests/phpunit/test-functions.php @@ -11,4 +11,35 @@ public function test_wp_stream_get_iso_8601_extended_date() { $offset_date = wp_stream_get_iso_8601_extended_date( $time, 5 ); $this->assertSame( $offset_date, '2004-09-16T23:59:58+0500' ); } + + /** + * After bootstrap the getter returns the same Plugin stored on the global. + */ + public function test_wp_stream_get_instance_returns_plugin_when_global_is_set() { + // Arrange + Act + $instance = wp_stream_get_instance(); + + // Assert + $this->assertInstanceOf( Plugin::class, $instance ); + $this->assertSame( $this->plugin, $instance ); + } + + /** + * AC 8: calling the getter before $GLOBALS['wp_stream'] is assigned must not warn. + */ + public function test_wp_stream_get_instance_returns_null_when_global_unset() { + // Arrange + $saved = $GLOBALS['wp_stream']; + unset( $GLOBALS['wp_stream'] ); + + try { + // Act — PHPUnit converts Warnings to exceptions; reaching Assert means none fired. + $result = wp_stream_get_instance(); + + // Assert + $this->assertNull( $result ); + } finally { + $GLOBALS['wp_stream'] = $saved; + } + } }