Files
2meet-data-optimizer/tests/integration/SchemaUpgradeDeferralTest.php
T
wpdev 29adb05567 fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix
- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(),
  and dbDelta on an existing table had no lock, so concurrent requests each
  ran the ALTER on a large table. The upgrade now runs under the same MySQL
  GET_LOCK as the index DDL (skipped when another process holds it) and
  re-checks the stored hash once locked; this covers aeav_only too. The table
  build moves unchanged to write_table(); lock()/unlock() are shared
- security review M1: after a deferred upgrade the group stays native until
  tmdo_drift_resync has rebuilt the rows written meanwhile
  (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat
  writes (disabled) it is released at once
- Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release),
  integration 585, unit 702, phpcs, stan
- PR: PR-111 (2.3 acceptance security review)
2026-09-27 17:22:57 +08:00

143 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
/**
* An existing table is altered by one process at a time: anonymous
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
*/
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
try {
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
} finally {
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
}
/**
* After the upgrade the group stays native until the drift fix has
* rebuilt the rows written meanwhile (security review M1).
*/
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
update_option(
TMDO_Schema_Manager::DEFERRED_OPTION,
array(
'user/sd_core' => 1,
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
'post/sd_core' => 1, // Another entity.
)
);
TMDO_Schema_Manager::resynced( 'user' );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
TMDO_Schema_Manager::resynced( 'user' );
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
}
}