Skip to content

Commit f8de37d

Browse files
dkotterdkotterjeffpaulJameswlepage
authored
Merge pull request #286 from dkotter/feature/migrate-credentials
Migrate credentials from the AI Credentials setup to the new Connectors setup Co-authored-by: dkotter <dkotter@git.wordpress.org> Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org> Co-authored-by: Jameswlepage <isotropic@git.wordpress.org>
2 parents 85a3a03 + 3d58538 commit f8de37d

File tree

3 files changed

+354
-0
lines changed

3 files changed

+354
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
/**
3+
* Credential migration.
4+
*
5+
* Migrates provider API keys from the legacy `wp_ai_client_provider_credentials`
6+
* option to the per-provider options used by the WordPress Connectors system.
7+
*
8+
* @package WordPress\AI\Migrations
9+
*/
10+
11+
declare( strict_types=1 );
12+
13+
namespace WordPress\AI\Migrations;
14+
15+
/**
16+
* Handles credential migration from the legacy storage format to the
17+
* WordPress Connectors-based storage format.
18+
*
19+
* @since x.x.x
20+
*/
21+
class Credential_Migration {
22+
23+
/**
24+
* The legacy option that stored all provider credentials as an array.
25+
*
26+
* @since x.x.x
27+
* @var string
28+
*/
29+
private const OLD_OPTION = 'wp_ai_client_provider_credentials';
30+
31+
/**
32+
* The option that tracks the last-run migration version.
33+
*
34+
* @since x.x.x
35+
* @var string
36+
*/
37+
private const VERSION_OPTION = 'ai_experiments_version';
38+
39+
/**
40+
* The plugin version at which this migration must run.
41+
*
42+
* @since x.x.x
43+
* @var string
44+
*/
45+
private const TARGET_VERSION = '0.5.0';
46+
47+
/**
48+
* Map of legacy provider array keys to their new Connectors option names.
49+
*
50+
* @since x.x.x
51+
*
52+
* @return array<string, string>
53+
*/
54+
private static function get_provider_map(): array {
55+
return array(
56+
'openai' => 'connectors_ai_openai_api_key',
57+
'google' => 'connectors_ai_google_api_key',
58+
'anthropic' => 'connectors_ai_anthropic_api_key',
59+
);
60+
}
61+
62+
/**
63+
* Runs the migration if the stored version is below the target version.
64+
*
65+
* Compares the stored version option against the target version and, when
66+
* an upgrade is detected, migrates credentials then records the new version.
67+
*
68+
* @since x.x.x
69+
*/
70+
public function run(): void {
71+
$stored_version = get_option( self::VERSION_OPTION, '0.0.0' );
72+
73+
if ( version_compare( (string) $stored_version, self::TARGET_VERSION, '>=' ) ) {
74+
return;
75+
}
76+
77+
$this->maybe_migrate_credentials();
78+
update_option( self::VERSION_OPTION, AI_EXPERIMENTS_VERSION );
79+
}
80+
81+
/**
82+
* Copies legacy provider credentials to the new per-provider options.
83+
*
84+
* Reads the old combined credentials option and, for each known provider,
85+
* copies the credential to the new option only when the new option is empty.
86+
*
87+
* @since x.x.x
88+
*/
89+
private function maybe_migrate_credentials(): void {
90+
$old_credentials = get_option( self::OLD_OPTION, array() );
91+
92+
if ( empty( $old_credentials ) || ! is_array( $old_credentials ) ) {
93+
return;
94+
}
95+
96+
foreach ( self::get_provider_map() as $provider => $new_option ) {
97+
if ( empty( $old_credentials[ $provider ] ) ) {
98+
continue;
99+
}
100+
101+
// Only migrate if the new option slot is empty.
102+
if ( '' !== get_option( $new_option, '' ) ) {
103+
continue;
104+
}
105+
106+
update_option( $new_option, $old_credentials[ $provider ] );
107+
}
108+
109+
delete_option( self::OLD_OPTION );
110+
}
111+
}

includes/bootstrap.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace WordPress\AI;
1313

1414
use WordPress\AI\Abilities\Utilities\Posts;
15+
use WordPress\AI\Migrations\Credential_Migration;
1516
use WordPress\AI\Settings\Settings_Page;
1617
use WordPress\AI\Settings\Settings_Registration;
1718

@@ -169,6 +170,9 @@ function load(): void {
169170
require_once AI_EXPERIMENTS_PLUGIN_DIR . 'includes/autoload.php';
170171
require_once AI_EXPERIMENTS_PLUGIN_DIR . 'includes/helpers.php';
171172

173+
// Run any pending migrations.
174+
( new Credential_Migration() )->run();
175+
172176
$loaded = true;
173177

174178
// Add plugin action links.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
<?php
2+
/**
3+
* Integration tests for Credential_Migration.
4+
*
5+
* @package WordPress\AI\Tests\Integration\Includes\Migrations
6+
*/
7+
8+
namespace WordPress\AI\Tests\Integration\Includes\Migrations;
9+
10+
use WP_UnitTestCase;
11+
use WordPress\AI\Migrations\Credential_Migration;
12+
13+
/**
14+
* Credential_Migration test case.
15+
*
16+
* @since x.x.x
17+
*/
18+
class Credential_MigrationTest extends WP_UnitTestCase {
19+
20+
/**
21+
* Returns the new-style Connectors option names under test.
22+
*
23+
* @since x.x.x
24+
*
25+
* @return list<string>
26+
*/
27+
private static function get_connector_options(): array {
28+
return array(
29+
'connectors_ai_openai_api_key',
30+
'connectors_ai_google_api_key',
31+
'connectors_ai_anthropic_api_key',
32+
);
33+
}
34+
35+
/**
36+
* Removes the WordPress Connectors sanitize and mask filters before each test.
37+
*
38+
* WordPress 7.0 registers a sanitize_callback for these options that validates
39+
* keys against a live provider registry (unavailable in tests), and an option_*
40+
* filter that masks values on read. Both are removed here so the tests can
41+
* write and read raw values directly.
42+
*
43+
* @since x.x.x
44+
*/
45+
public function setUp(): void {
46+
parent::setUp();
47+
48+
foreach ( self::get_connector_options() as $option ) {
49+
remove_all_filters( 'sanitize_option_' . $option );
50+
remove_filter( 'option_' . $option, '_wp_connectors_mask_api_key' );
51+
}
52+
}
53+
54+
/**
55+
* Deletes all options written during a test and restores the mask filters.
56+
*
57+
* @since x.x.x
58+
*/
59+
public function tearDown(): void {
60+
delete_option( 'wp_ai_client_provider_credentials' );
61+
delete_option( 'ai_experiments_version' );
62+
63+
foreach ( self::get_connector_options() as $option ) {
64+
delete_option( $option );
65+
add_filter( 'option_' . $option, '_wp_connectors_mask_api_key' );
66+
}
67+
68+
parent::tearDown();
69+
}
70+
71+
/**
72+
* Tests that run() migrates all provider credentials to the new options.
73+
*
74+
* @since x.x.x
75+
*/
76+
public function test_run_migrates_credentials() {
77+
update_option(
78+
'wp_ai_client_provider_credentials',
79+
array(
80+
'openai' => 'sk-openai-key',
81+
'google' => 'google-key',
82+
'anthropic' => 'anthropic-key',
83+
)
84+
);
85+
86+
( new Credential_Migration() )->run();
87+
88+
$this->assertEquals( 'sk-openai-key', get_option( 'connectors_ai_openai_api_key' ) );
89+
$this->assertEquals( 'google-key', get_option( 'connectors_ai_google_api_key' ) );
90+
$this->assertEquals( 'anthropic-key', get_option( 'connectors_ai_anthropic_api_key' ) );
91+
}
92+
93+
/**
94+
* Tests that run() stores the current plugin version after migrating.
95+
*
96+
* @since x.x.x
97+
*/
98+
public function test_run_stores_version_after_migration() {
99+
( new Credential_Migration() )->run();
100+
101+
$this->assertEquals( AI_EXPERIMENTS_VERSION, get_option( 'ai_experiments_version' ) );
102+
}
103+
104+
/**
105+
* Tests that run() is a no-op when the stored version is already at the target.
106+
*
107+
* @since x.x.x
108+
*/
109+
public function test_run_skips_when_version_already_current() {
110+
update_option( 'ai_experiments_version', '0.5.0' );
111+
update_option(
112+
'wp_ai_client_provider_credentials',
113+
array( 'openai' => 'sk-old-key' )
114+
);
115+
116+
( new Credential_Migration() )->run();
117+
118+
$this->assertNull(
119+
$this->get_option_from_db( 'connectors_ai_openai_api_key' ),
120+
'Should not write new option when version is current'
121+
);
122+
}
123+
124+
/**
125+
* Tests that run() writes no new options when no old credentials exist (fresh install).
126+
*
127+
* @since x.x.x
128+
*/
129+
public function test_run_does_nothing_on_fresh_install() {
130+
( new Credential_Migration() )->run();
131+
132+
foreach ( self::get_connector_options() as $option ) {
133+
$this->assertNull(
134+
$this->get_option_from_db( $option ),
135+
"$option should not be written on fresh install"
136+
);
137+
}
138+
}
139+
140+
/**
141+
* Tests that run() does not overwrite an already-set new-style credential.
142+
*
143+
* @since x.x.x
144+
*/
145+
public function test_run_does_not_overwrite_existing_new_credentials() {
146+
update_option( 'connectors_ai_openai_api_key', 'sk-already-set' );
147+
update_option(
148+
'wp_ai_client_provider_credentials',
149+
array( 'openai' => 'sk-old-key' )
150+
);
151+
152+
( new Credential_Migration() )->run();
153+
154+
$this->assertEquals(
155+
'sk-already-set',
156+
get_option( 'connectors_ai_openai_api_key' ),
157+
'Existing new credential should not be overwritten'
158+
);
159+
}
160+
161+
/**
162+
* Tests that run() only migrates providers whose new credential option is empty.
163+
*
164+
* @since x.x.x
165+
*/
166+
public function test_run_migrates_only_providers_missing_new_credentials() {
167+
update_option( 'connectors_ai_openai_api_key', 'sk-already-set' );
168+
update_option(
169+
'wp_ai_client_provider_credentials',
170+
array(
171+
'openai' => 'sk-old-key',
172+
'anthropic' => 'anthropic-old-key',
173+
)
174+
);
175+
176+
( new Credential_Migration() )->run();
177+
178+
$this->assertEquals(
179+
'sk-already-set',
180+
get_option( 'connectors_ai_openai_api_key' ),
181+
'OpenAI credential should not be overwritten'
182+
);
183+
$this->assertEquals(
184+
'anthropic-old-key',
185+
get_option( 'connectors_ai_anthropic_api_key' ),
186+
'Anthropic credential should be migrated'
187+
);
188+
}
189+
190+
/**
191+
* Tests that a second call to run() after migration is already complete is a no-op.
192+
*
193+
* @since x.x.x
194+
*/
195+
public function test_run_is_idempotent() {
196+
update_option(
197+
'wp_ai_client_provider_credentials',
198+
array( 'openai' => 'sk-openai-key' )
199+
);
200+
201+
$migration = new Credential_Migration();
202+
$migration->run();
203+
204+
// Simulate the old option being changed after migration has already run.
205+
update_option(
206+
'wp_ai_client_provider_credentials',
207+
array( 'openai' => 'sk-different-key' )
208+
);
209+
210+
$migration->run();
211+
212+
$this->assertEquals(
213+
'sk-openai-key',
214+
get_option( 'connectors_ai_openai_api_key' ),
215+
'Second run should not re-migrate'
216+
);
217+
}
218+
219+
/**
220+
* Returns the raw option value directly from the database, bypassing all filters.
221+
*
222+
* Returns null if the option row does not exist.
223+
*
224+
* @since x.x.x
225+
*
226+
* @param string $option_name The option name to look up.
227+
* @return string|null The raw value, or null if the row is absent.
228+
*/
229+
private function get_option_from_db( string $option_name ): ?string {
230+
global $wpdb;
231+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
232+
return $wpdb->get_var(
233+
$wpdb->prepare(
234+
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
235+
$option_name
236+
)
237+
);
238+
}
239+
}

0 commit comments

Comments
 (0)