Skip to content

Commit 3c5f4d9

Browse files
arraypressclaude
andcommitted
Add set_value(), so a setting can be written from code
get_value() had no counterpart, which made an encrypted field writable only through the form. update_option() stores plaintext where ciphertext is expected, and the read side authenticates before decrypting -- so the value comes back as an empty string rather than as what was written. An activation routine, a WP-CLI command or a test that sets an API key that way finds it silently missing, with nothing logged and nothing thrown. Targeted rather than going through FieldSet::save(), which iterates every field in the set and deletes the ones absent from its input -- so writing one value that way would clear the rest of the page. delete_value() is the same for removal, and set_setting_field_value() is the global wrapper matching the existing getters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tk8y5eZa3CHVVSM6nVxsJw
1 parent 90b7ea7 commit 3c5f4d9

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

‎src/Core.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ function get_setting_fields( string $id ): ?SettingFields {
4444

4545
/** Value Access **************************************************************/
4646

47+
if ( ! function_exists( 'set_setting_field_value' ) ) {
48+
/**
49+
* Write one setting, through the decorators a form save uses.
50+
*
51+
* The only way to set an encrypted field from code. update_option()
52+
* stores plaintext where ciphertext is expected, and it reads back empty.
53+
*
54+
* @param string $settings_id Registered settings id.
55+
* @param string $field_key The field.
56+
* @param mixed $value What to store.
57+
*
58+
* @return bool
59+
*/
60+
function set_setting_field_value( string $settings_id, string $field_key, mixed $value ): bool {
61+
$settings = Registry::instance()->get( $settings_id );
62+
63+
return $settings ? $settings->set_value( $field_key, $value ) : false;
64+
}
65+
}
66+
4767
if ( ! function_exists( 'get_setting_field_value' ) ) {
4868
/**
4969
* Get a setting value with automatic decryption and constant fallback.

‎src/SettingFields.php‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,57 @@ public function get_value( string $field_key, mixed $fallback = null ): mixed {
12441244
*
12451245
* @return array<string, array<string, mixed>>
12461246
*/
1247+
/**
1248+
* Write one value, through the same decorators a form save uses.
1249+
*
1250+
* The counterpart to get_value(), and the only way to set a field marked
1251+
* encrypted from code. Writing the option directly stores plaintext where
1252+
* ciphertext is expected, and the read side authenticates before
1253+
* decrypting -- so the value comes back as an empty string rather than as
1254+
* what was written. An activation routine, a WP-CLI command or a test
1255+
* that sets an API key that way finds it silently missing.
1256+
*
1257+
* Targeted rather than going through FieldSet::save(), which iterates
1258+
* every field in the set and deletes the ones absent from its input.
1259+
*
1260+
* @param string $field_key The field.
1261+
* @param mixed $value What to store.
1262+
*
1263+
* @return bool False when there is no such field.
1264+
*/
1265+
public function set_value( string $field_key, mixed $value ): bool {
1266+
$field = $this->set()->field( $field_key );
1267+
1268+
if ( null === $field ) {
1269+
return false;
1270+
}
1271+
1272+
$this->context->write( 0, $field, $value );
1273+
$this->options->save();
1274+
1275+
return true;
1276+
}
1277+
1278+
/**
1279+
* Remove one value.
1280+
*
1281+
* @param string $field_key The field.
1282+
*
1283+
* @return bool False when there is no such field.
1284+
*/
1285+
public function delete_value( string $field_key ): bool {
1286+
$field = $this->set()->field( $field_key );
1287+
1288+
if ( null === $field ) {
1289+
return false;
1290+
}
1291+
1292+
$this->context->delete( 0, $field );
1293+
$this->options->save();
1294+
1295+
return true;
1296+
}
1297+
12471298
public function get_fields(): array {
12481299
return $this->fields;
12491300
}

‎tests/SetValueTest.php‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
/**
3+
* Writing a value from code.
4+
*
5+
* @package ArrayPress\RegisterSettingFields
6+
*/
7+
8+
declare( strict_types=1 );
9+
10+
namespace ArrayPress\RegisterSettingFields\Tests;
11+
12+
use ArrayPress\RegisterSettingFields\Registry;
13+
use ArrayPress\RegisterSettingFields\SettingFields;
14+
use PHPUnit\Framework\TestCase;
15+
16+
final class SetValueTest extends TestCase {
17+
18+
protected function setUp(): void {
19+
sf_reset_globals();
20+
21+
parent::setUp();
22+
}
23+
24+
protected function tearDown(): void {
25+
foreach ( array_keys( Registry::instance()->all() ) as $id ) {
26+
Registry::instance()->unregister( (string) $id );
27+
}
28+
29+
sf_reset_globals();
30+
31+
parent::tearDown();
32+
}
33+
34+
private function page(): SettingFields {
35+
return new SettingFields(
36+
'sf_set',
37+
[
38+
'option_name' => 'sf_set',
39+
'menu_slug' => 'sf-set',
40+
'tabs' => [ 'one' => 'One' ],
41+
'fields' => [
42+
'plain' => [ 'type' => 'text', 'tab' => 'one' ],
43+
'secret' => [ 'type' => 'password', 'tab' => 'one', 'encrypted' => true ],
44+
'other' => [ 'type' => 'text', 'tab' => 'one' ],
45+
],
46+
]
47+
);
48+
}
49+
50+
public function test_a_value_round_trips(): void {
51+
$page = $this->page();
52+
53+
$this->assertTrue( $page->set_value( 'plain', 'hello' ) );
54+
$this->assertSame( 'hello', $page->get_value( 'plain' ) );
55+
}
56+
57+
/**
58+
* The reason this method exists. update_option() would store plaintext
59+
* where ciphertext is expected, and the read side authenticates before
60+
* decrypting -- so it comes back empty rather than as what was written.
61+
*/
62+
public function test_an_encrypted_value_round_trips(): void {
63+
$page = $this->page();
64+
65+
$this->assertTrue( $page->set_value( 'secret', 'sk_test_example' ) );
66+
$this->assertSame( 'sk_test_example', $page->get_value( 'secret' ) );
67+
}
68+
69+
/**
70+
* Stored encrypted, not merely returned correctly.
71+
*/
72+
public function test_an_encrypted_value_is_not_stored_in_the_clear(): void {
73+
$this->page()->set_value( 'secret', 'sk_test_example' );
74+
75+
$stored = get_option( 'sf_set', [] );
76+
77+
$this->assertNotSame( 'sk_test_example', $stored['secret'] ?? null );
78+
$this->assertNotEmpty( $stored['secret'] ?? '' );
79+
}
80+
81+
/**
82+
* Targeted, unlike a form save, which deletes every field absent from
83+
* its input.
84+
*/
85+
public function test_writing_one_field_leaves_the_others_alone(): void {
86+
$page = $this->page();
87+
88+
$page->set_value( 'plain', 'first' );
89+
$page->set_value( 'other', 'second' );
90+
$page->set_value( 'plain', 'changed' );
91+
92+
$this->assertSame( 'changed', $page->get_value( 'plain' ) );
93+
$this->assertSame( 'second', $page->get_value( 'other' ) );
94+
}
95+
96+
public function test_an_unknown_field_writes_nothing(): void {
97+
$this->assertFalse( $this->page()->set_value( 'nothing', 'x' ) );
98+
}
99+
100+
public function test_a_value_can_be_removed(): void {
101+
$page = $this->page();
102+
103+
$page->set_value( 'plain', 'hello' );
104+
105+
$this->assertTrue( $page->delete_value( 'plain' ) );
106+
$this->assertNull( $page->get_value( 'plain' ) );
107+
$this->assertFalse( $page->delete_value( 'nothing' ) );
108+
}
109+
}

0 commit comments

Comments
 (0)