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)
This commit is contained in:
2026-09-27 17:22:57 +08:00
parent 0aec4b9bb6
commit 29adb05567
4 changed files with 149 additions and 20 deletions
@@ -88,4 +88,55 @@ class SchemaUpgradeDeferralTest extends TestCase {
$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' ) );
}
}