diff --git a/inc/Core/Steps/Settings/SettingsHandler.php b/inc/Core/Steps/Settings/SettingsHandler.php index 5e6efbf4d..02f65fa34 100644 --- a/inc/Core/Steps/Settings/SettingsHandler.php +++ b/inc/Core/Steps/Settings/SettingsHandler.php @@ -93,6 +93,9 @@ protected static function sanitizeField( array $raw_settings, string $key, array case 'textarea': return self::sanitizeTextarea( $raw_settings, $key, $default ); + case 'json': + return self::sanitizeJson( $raw_settings, $key, $default ); + case 'text': default: return self::sanitizeText( $raw_settings, $key, $default ); @@ -168,6 +171,49 @@ protected static function sanitizeTextarea( array $raw_settings, string $key, $d return sanitize_textarea_field( wp_unslash( $value ) ); } + /** + * Sanitize JSON field. + * + * JSON fields accept either a JSON-encoded string (which gets decoded to + * an associative array) or an already-decoded array. The previous default + * sanitization path coerced array values to empty strings via + * sanitize_text_field(), which corrupted nested handler configuration + * (see #2059). Invalid input falls back to the schema default or an empty + * array, never to a scalar string that would later trip array_merge(). + * + * @param array $raw_settings Raw settings array. + * @param string $key Field key. + * @param mixed $default Default value (typically a JSON string like '{}'). + * @return array Sanitized JSON value as an associative array. + */ + protected static function sanitizeJson( array $raw_settings, string $key, $default_value ): array { + $value = $raw_settings[ $key ] ?? $default_value; + + if ( is_array( $value ) ) { + return $value; + } + + if ( is_string( $value ) && '' !== $value ) { + $decoded = json_decode( wp_unslash( $value ), true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + } + + if ( is_string( $default_value ) && '' !== $default_value ) { + $decoded_default = json_decode( $default_value, true ); + if ( is_array( $decoded_default ) ) { + return $decoded_default; + } + } + + if ( is_array( $default_value ) ) { + return $default_value; + } + + return array(); + } + /** * Sanitize checkbox field. * diff --git a/tests/flow-update-handler-config-params-smoke.php b/tests/flow-update-handler-config-params-smoke.php new file mode 100644 index 000000000..5e2f537b5 --- /dev/null +++ b/tests/flow-update-handler-config-params-smoke.php @@ -0,0 +1,260 @@ + 'dispatch_message', + 'params' => array( + 'channel' => 'example-channel', + 'recipient' => 'example-recipient', + 'message' => 'hello world', + ), +); + +$sanitized = SystemTaskSettings::sanitize( $raw ); + +smoke_assert( + is_array( $sanitized['params'] ?? null ), + 'params remains an array after sanitize' +); +smoke_assert( + '' !== ( $sanitized['params'] ?? '' ), + 'params is not coerced to empty string' +); +smoke_assert_equals( + array( + 'channel' => 'example-channel', + 'recipient' => 'example-recipient', + 'message' => 'hello world', + ), + $sanitized['params'] ?? null, + 'nested params keys round-trip verbatim' +); + +echo "\n[2] JSON-string params is decoded into a nested array:\n"; + +$raw_json_string = array( + 'task' => 'dispatch_message', + 'params' => '{"channel":"json-channel","recipient":"json-recipient","message":"from json"}', +); + +$sanitized_json = SystemTaskSettings::sanitize( $raw_json_string ); + +smoke_assert( + is_array( $sanitized_json['params'] ?? null ), + 'JSON-string params decodes to array' +); +smoke_assert_equals( + array( + 'channel' => 'json-channel', + 'recipient' => 'json-recipient', + 'message' => 'from json', + ), + $sanitized_json['params'] ?? null, + 'JSON-string params decodes to the same nested shape' +); + +echo "\n[3] params is never a scalar string (regression guard for SystemTaskStep array_merge):\n"; + +$cases = array( + 'nested array' => array( + 'task' => 'dispatch_message', + 'params' => array( 'a' => 1, 'b' => 2 ), + ), + 'JSON string' => array( + 'task' => 'dispatch_message', + 'params' => '{"a":1,"b":2}', + ), + 'missing key' => array( + 'task' => 'dispatch_message', + ), + 'empty array' => array( + 'task' => 'dispatch_message', + 'params' => array(), + ), + 'invalid JSON' => array( + 'task' => 'dispatch_message', + 'params' => 'not-json-at-all', + ), + 'empty string' => array( + 'task' => 'dispatch_message', + 'params' => '', + ), +); + +foreach ( $cases as $key => $input ) { + $result = SystemTaskSettings::sanitize( $input ); + smoke_assert( + is_array( $result['params'] ?? null ), + "params is an array (case: {$key})", + 'got ' . gettype( $result['params'] ?? null ) + ); + // The whole point: array_merge() must not fatal on this value. + $merged = null; + try { + $merged = array_merge( $result['params'], array( 'task_type' => 'dispatch_message' ) ); + } catch ( \TypeError $e ) { + // Pinned regression: pre-fix, this threw "Argument #1 must be of type array, string given". + $merged = $e->getMessage(); + } + smoke_assert( + is_array( $merged ), + "array_merge(params, [...]) succeeds (case: {$key})", + is_string( $merged ) ? $merged : '' + ); +} + +echo "\n-------------------------------------------------------\n"; +$total = $passes + count( $failures ); +echo "{$passes} / {$total} passed\n"; + +if ( ! empty( $failures ) ) { + echo "\nFailures:\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit( 1 ); +} + +echo "\nAll assertions passed.\n";