From 064f4e095557d6cad80075d0bedc755ec4aee3ca Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Oct 2026 19:45:57 +0800 Subject: [PATCH] fix(admin): keep enum values in their declared type when sanitizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 积分兑换明细.level radio declares its options as the integers 1 and 2, but sanitize_field_value() ran every radio value through sanitize_text_field(), so the sanitized payload held the string 1. validate() then compared with in_array(..., true) and rejected both default rows, which made the VIP payment section impossible to save. Resolve enum values to the value declared by their option during sanitization, covering select, radio, palette and image_select. A value that matches no option still falls through to the previous string coercion, so invalid input keeps producing a precise validation error. This also keeps the stored type aligned with the schema, which the naive-ui radio group compares with === when it re-renders a saved setting. Verified with an end-to-end sanitize, validate, write and read round-trip, and locked in with a contract test in the VIP structured-settings suite. The widget-system, admin, front-submit and navigation suites all pass (658 tests). --- src/Admin/SchemaProvider.php | 50 +++++++++++++++++ .../vip-settings-structured-contract.test.cjs | 24 +++++++++ .../vip-settings-structured-harness.php | 53 +++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 52d12ab5..503ae9e6 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -866,6 +866,47 @@ class SchemaProvider { return null; } + /** + * Resolve a submitted enum value to the value declared by its option. + * + * Payloads reach the sanitizer as strings (or numbers), so a strict match + * against the schema would reject a valid choice whenever the option value + * is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the + * SPA submits them as numbers and sanitize_text_field() then turns them into + * "1" / "2". Returning the declared value keeps the stored type aligned with + * the schema, which the radio/select controls compare with === when they + * re-render a saved setting. + * + * Returns null when nothing matches, so validation still reports the + * invalid value instead of silently rewriting the operator's input. + * + * @param mixed $value + * @param array $options + * @return mixed + */ + private function match_option_value($value, array $options) { + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if ($opt_value === $value) { + return $opt_value; + } + } + if (is_bool($value) || !is_scalar($value)) { + return null; + } + $needle = (string) $value; + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if (is_bool($opt_value) || !is_scalar($opt_value)) { + continue; + } + if ((string) $opt_value === $needle) { + return $opt_value; + } + } + return null; + } + private function sanitize_field_value($value, array $field) { if (!empty($field['captcha_provider'])) { return function_exists('aksrt_captcha_sanitize_provider_config') @@ -898,6 +939,15 @@ class SchemaProvider { return $normalized !== null ? $normalized : (string) ($field['default'] ?? ''); } + // Enum fields keep the option's declared value so the stored type always + // matches what validate() compares against with in_array(..., true). + if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && empty($field['multiple']) && !empty($field['options'])) { + $matched = $this->match_option_value($value, (array) $field['options']); + if ($matched !== null) { + return $matched; + } + } + if (in_array($type, ['fieldset', 'accordion'], true)) { return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? [])); } diff --git a/tests/widget-system/vip-settings-structured-contract.test.cjs b/tests/widget-system/vip-settings-structured-contract.test.cjs index fd5475e5..01c0797a 100644 --- a/tests/widget-system/vip-settings-structured-contract.test.cjs +++ b/tests/widget-system/vip-settings-structured-contract.test.cjs @@ -288,6 +288,30 @@ test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' }); }); +test('积分兑换明细.level 枚举净化后仍能通过校验,非法值依旧报错', () => { + const { sanitize } = harness; + + // level 的选项值是整数 1 / 2。默认两行提交数字即可保存,且净化后必须保持 + // 整数类型:前端 n-radio 用 === 比较保存值,字符串化会让单选框失活。 + assert.strictEqual(sanitize.default_rows.valid, true); + assert.deepStrictEqual(sanitize.default_rows.errors, []); + assert.deepStrictEqual(sanitize.default_rows.points_rows.map((r) => r.level), [1, 1]); + + // SPA 提交字符串等级时,同样收敛回选项声明的整数。 + assert.strictEqual(sanitize.string_level.valid, true); + assert.deepStrictEqual(sanitize.string_level.points_rows.map((r) => r.level), [2]); + + // 收敛不得掩盖真正的非法值:level 与 unit 仍各自报出精确错误。 + assert.strictEqual(sanitize.invalid_level.valid, false); + assert.deepStrictEqual(sanitize.invalid_level.errors, ['积分兑换明细[0].level has an invalid value']); + assert.strictEqual(sanitize.invalid_unit.valid, false); + assert.deepStrictEqual(sanitize.invalid_unit.errors, ['积分兑换明细[0].unit has an invalid value']); + + // 字符串型枚举(palette)不受影响。 + assert.strictEqual(sanitize.invalid_tag_class.valid, false); + assert.deepStrictEqual(sanitize.invalid_tag_class.errors, ['会员商品[0].tag_class has an invalid value']); +}); + test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => { const defaults = harness.runtime.defaults; // 参考主题默认两行。 diff --git a/tests/widget-system/vip-settings-structured-harness.php b/tests/widget-system/vip-settings-structured-harness.php index f46c6aca..0de00ada 100644 --- a/tests/widget-system/vip-settings-structured-harness.php +++ b/tests/widget-system/vip-settings-structured-harness.php @@ -61,6 +61,7 @@ namespace { require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php'; require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php'; + require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php'; require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php'; /** Exposes the trait's private renew-pricing math for contract assertions. */ @@ -171,6 +172,57 @@ namespace { ]; $legacy = PaymentRuntime::get_payment_vip_settings(); + // ---- sanitize -> validate round-trip ----------------------------------- + // 复现并锁定:积分兑换明细.level 的选项值为整数,而 SPA 提交的可能是数字 + // 或字符串;净化必须把值收敛回选项声明的类型,否则严格 in_array 校验会以 + // "has an invalid value" 拒绝一次完全合法的保存。 + $sanitize = (static function (): array { + $provider = \AKSRT\Admin\SchemaProvider::get_instance(); + $cases = [ + 'default_rows' => [ + 'pay_vip_points_exchange_s' => true, + 'pay_vip_points_exchange_product' => [ + ['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'], + ['points' => 5000, 'level' => 1, 'time' => 'Permanent'], + ], + ], + 'string_level' => [ + 'pay_vip_points_exchange_s' => true, + 'pay_vip_points_exchange_product' => [ + ['points' => 800, 'level' => '2', 'time' => 3, 'unit' => 'month'], + ], + ], + 'invalid_level' => [ + 'pay_vip_points_exchange_s' => true, + 'pay_vip_points_exchange_product' => [ + ['points' => 500, 'level' => 9, 'time' => 30, 'unit' => 'day'], + ], + ], + 'invalid_unit' => [ + 'pay_vip_points_exchange_s' => true, + 'pay_vip_points_exchange_product' => [ + ['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'year'], + ], + ], + 'invalid_tag_class' => [ + 'vip_1_product' => [ + ['price' => '88.80', 'show_price' => '99', 'tag' => 'x', 'tag_class' => 'nope', 'time' => 1, 'unit' => 'month'], + ], + ], + ]; + $out = []; + foreach ($cases as $name => $payload) { + $clean = $provider->sanitize_payload('payment', $payload, 'vip'); + $result = $provider->validate('payment', $clean, 'vip'); + $out[$name] = [ + 'points_rows' => $clean['pay_vip_points_exchange_product'] ?? null, + 'valid' => $result['valid'], + 'errors' => $result['errors'], + ]; + } + return $out; + })(); + echo wp_json_encode([ 'schema' => [ 'version' => $vip['version'], @@ -187,6 +239,7 @@ namespace { 'custom' => $custom, 'legacy' => $legacy, ], + 'sanitize' => $sanitize, 'accordion' => (static function (array $groups): array { $out = ['groups' => [], 'sections' => []]; foreach ($groups as $group) {