prefix = self::TEST_PREFIX; $wpdb->options = self::TEST_PREFIX . 'options'; $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` ( option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, option_name varchar(191) NOT NULL DEFAULT "", option_value longtext NOT NULL, autoload varchar(20) NOT NULL DEFAULT "yes", PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) DEFAULT CHARACTER SET utf8mb4' ); // Define WP auth constants for stable key derivation. if ( ! defined( 'AUTH_KEY' ) ) { define( 'AUTH_KEY', 'integration_auth_key_long_enough_xxxxxxxxxxxxxxxxxxxxxx' ); } if ( ! defined( 'SECURE_AUTH_SALT' ) ) { define( 'SECURE_AUTH_SALT', 'integration_secure_auth_salt_long_xxxxxxxxxxxxxxxxxxxx' ); } } protected function setUp(): void { global $wpdb; // Clear all wpdo_* options before each test for isolation. $wpdb->query( "DELETE FROM `" . self::TEST_PREFIX . "options` WHERE option_name LIKE 'wpdo_%' OR option_name LIKE 'unrelated_%'" ); } public function test_migrate_mixed_format_inputs(): void { // Set up: 2 v1 blobs, 1 v2 blob, 1 plaintext, 1 unrelated (non-wpdo). $plain1 = 'https://hooks.slack.com/services/legacy1'; $plain2 = 'https://discord.com/api/webhooks/legacy2'; $this->insert_v1_option( 'wpdo_legacy_slack', $plain1 ); $this->insert_v1_option( 'wpdo_legacy_discord', $plain2 ); // Already v2. $this->set_option_raw( 'wpdo_already_v2', WPDO_Crypto::encrypt( 'already encrypted' ) ); // Plaintext. $this->set_option_raw( 'wpdo_plaintext_secret', 'just text' ); // Unrelated prefix — must NOT be touched. $this->set_option_raw( 'unrelated_secret', 'should be ignored' ); $counts = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); // Scanned 4 wpdo_* options (unrelated_ excluded). $this->assertSame( 4, $counts['scanned'] ); $this->assertSame( 2, $counts['migrated'] ); $this->assertSame( 1, $counts['already_v2'] ); $this->assertSame( 1, $counts['plaintext'] ); $this->assertSame( 0, $counts['failed'] ); // Verify v1 blobs were upgraded to v2 and decrypt correctly. $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_slack' ) ); $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_discord' ) ); $this->assertSame( $plain1, WPDO_Crypto::get_option( 'wpdo_legacy_slack' ) ); $this->assertSame( $plain2, WPDO_Crypto::get_option( 'wpdo_legacy_discord' ) ); // Plaintext untouched. $this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'wpdo_plaintext_secret' ) ); // Unrelated option untouched. global $wpdb; $unrelated_value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM `" . self::TEST_PREFIX . "options` WHERE option_name = %s", 'unrelated_secret' ) ); $this->assertSame( 'should be ignored', $unrelated_value ); } public function test_migrate_idempotent_second_run_is_noop(): void { $plain = 'a value'; $this->insert_v1_option( 'wpdo_test_idempotent', $plain ); $first = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); $second = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); // First run migrates 1, second run sees it as already_v2. $this->assertSame( 1, $first['migrated'] ); $this->assertSame( 0, $second['migrated'] ); $this->assertSame( 1, $second['already_v2'] ); // Value still decrypts correctly after both runs. $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_test_idempotent' ) ); } public function test_migrate_empty_set(): void { $counts = WPDO_Crypto::migrate_v1_to_v2( 'nonexistent_prefix_' ); $this->assertSame( 0, $counts['scanned'] ); $this->assertSame( 0, $counts['migrated'] ); $this->assertSame( 0, $counts['failed'] ); } public function test_migrate_preserves_value_semantics(): void { // Realistic test: write a webhook-shaped string that includes URL chars // + special padding to make sure no encoding artifacts surface. $plain = 'https://hooks.slack.com/services/T01/B02/=+&%/special?chars=true'; $this->insert_v1_option( 'wpdo_realistic_webhook', $plain ); WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_realistic_webhook' ) ); } // ── Helpers ────────────────────────────────────────────────────────────── /** * Insert an option containing a hand-crafted v1 (CBC) ciphertext. */ private function insert_v1_option( string $name, string $plaintext ): void { $key = substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', AUTH_KEY . SECURE_AUTH_SALT, true ), 0, 32 ); $iv = random_bytes( 16 ); $ct = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct ); $this->set_option_raw( $name, $blob ); } /** * Write a raw option value directly (bypasses WPDO_Crypto::set_option). */ private function set_option_raw( string $name, string $value ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'REPLACE INTO `' . self::TEST_PREFIX . 'options` (option_name, option_value, autoload) VALUES (%s, %s, %s)', $name, $value, 'no' ) ); $GLOBALS['_wp_options'][ $name ] = $value; } }