Files
wordpress-theme-aksrt/tests/widget-system/oauth-security-harness.php
T
kate522andClaude Opus 5 dbb7de41e9 feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 23:29:51 +08:00

171 lines
9.6 KiB
PHP

<?php
/**
* Minimal executable harness for OAuth transaction, proxy, URL, and secret behavior.
*/
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('MONTH_IN_SECONDS', 2592000);
define('ARRAY_A', 'ARRAY_A');
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_cache'] = [];
$GLOBALS['captcha_enabled'] = true;
function aksrt_is_captcha_enabled_for(string $scene): bool { return $scene === 'bind' && !empty($GLOBALS['captcha_enabled']); }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($value)) ?? ''; }
function sanitize_text_field(string $value): string { return trim(strip_tags($value)); }
function esc_url_raw(string $value): string { return trim($value); }
function wp_unslash($value) { return $value; }
function wp_parse_url(string $url) { return parse_url($url); }
function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-' . substr(bin2hex(random_bytes(6)), 0, 12); }
function wp_salt(string $scheme = 'auth'): string { return 'harness-salt-' . $scheme; }
function is_ssl(): bool { return true; }
function maybe_serialize($value): string { return serialize($value); }
function maybe_unserialize($value) { $result = @unserialize((string) $value); return $result === false && $value !== serialize(false) ? $value : $result; }
function wp_cache_delete(string $key, string $group = ''): void { unset($GLOBALS['wp_cache'][$group][$key]); }
function get_option(string $key, $default = false) { return array_key_exists($key, $GLOBALS['wp_options']) ? $GLOBALS['wp_options'][$key] : $default; }
function add_option(string $key, $value, string $deprecated = '', bool $autoload = true): bool {
if (array_key_exists($key, $GLOBALS['wp_options'])) return false;
$GLOBALS['wp_options'][$key] = $value;
return true;
}
function update_option(string $key, $value, $autoload = null): bool {
$changed = !array_key_exists($key, $GLOBALS['wp_options']) || $GLOBALS['wp_options'][$key] !== $value;
$GLOBALS['wp_options'][$key] = $value;
return $changed;
}
function delete_option(string $key): bool {
if (!array_key_exists($key, $GLOBALS['wp_options'])) return false;
unset($GLOBALS['wp_options'][$key]);
return true;
}
function get_template_directory_uri(): string { return 'https://example.com/theme'; }
function get_theme_file_uri(string $path = ''): string { return 'https://example.com/theme/' . ltrim($path, '/'); }
function get_theme_file_path(string $path = ''): string { return dirname(__DIR__, 2) . '/' . ltrim($path, '/'); }
function sanitize_email(string $value): string { return filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : ''; }
function esc_html__(string $value, string $domain = ''): string { return $value; }
final class HarnessWpdb {
public string $options = 'wp_options';
public function prepare(string $query, ...$args): array { return [$query, $args]; }
public function esc_like(string $value): string { return $value; }
public function query($prepared): int {
[$query, $args] = $prepared;
if (str_starts_with(ltrim($query), 'DELETE')) {
[$key, $serialized] = $args;
if (!array_key_exists($key, $GLOBALS['wp_options']) || serialize($GLOBALS['wp_options'][$key]) !== $serialized) return 0;
unset($GLOBALS['wp_options'][$key]);
return 1;
}
if (str_starts_with(ltrim($query), 'UPDATE')) {
[$new, $key, $old] = $args;
if (!array_key_exists($key, $GLOBALS['wp_options']) || serialize($GLOBALS['wp_options'][$key]) !== $old) return 0;
$GLOBALS['wp_options'][$key] = unserialize($new);
return 1;
}
return 0;
}
public function get_results($prepared, $format): array { return []; }
}
$GLOBALS['wpdb'] = new HarnessWpdb();
}
namespace AKSRT\Settings {
final class Settings {
public static function flush_settings_cache(): void {}
public static function sync_native_site_icon(string $value): void {}
}
}
namespace {
require dirname(__DIR__, 2) . '/src/Auth/OAuthTransactionStore.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthUrlPolicy.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthProxyProtocol.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/Users/SchemaUsersTicket.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
$_COOKIE['aksrt_oauth_browser'] = str_repeat('A', 43);
$token = \AKSRT\Auth\OAuthTransactionStore::issue('direct', ['provider' => 'github'], 60, true);
check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'direct', true) !== null, 'transaction peek failed');
check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'other', true) === null, 'transaction kind mismatch accepted');
$_COOKIE['aksrt_oauth_browser'] = str_repeat('B', 43);
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'wrong browser claimed transaction');
$_COOKIE['aksrt_oauth_browser'] = str_repeat('A', 43);
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) !== null, 'valid browser failed claim');
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'transaction replay accepted');
$bindPass = \AKSRT\Auth\OAuthTransactionStore::issue('oauth_bind_pass', ['user_id' => 17, 'provider' => 'github'], 300, true);
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, false), 'valid bind pass peek failed');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'gitee', 17, true), 'wrong bind provider consumed as valid');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 18, true), 'wrong bind user consumed as valid');
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'valid bind pass claim failed after rejected mismatches');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'bind pass replay accepted');
$GLOBALS['captcha_enabled'] = false;
check(\AKSRT\Auth\OAuth::validate_bind_pass('', 'github', 17, true), 'disabled bind captcha required a pass');
$GLOBALS['captcha_enabled'] = true;
$expired = \AKSRT\Auth\OAuthTransactionStore::issue('direct', [], 30, true);
foreach ($GLOBALS['wp_options'] as $key => $row) {
if (is_array($row) && ($row['kind'] ?? '') === 'direct') $GLOBALS['wp_options'][$key]['expires_at'] = time() - 1;
}
check(\AKSRT\Auth\OAuthTransactionStore::peek($expired, 'direct', true) === null, 'expired transaction accepted');
$request = [
'v' => '2', 'request_id' => str_repeat('C', 43), 'provider' => 'github', 'action' => 'login',
'agent_back_url' => 'https://example.com/oauth/proxy/callback/', 'ts' => (string) time(),
];
$secret = 'test-secret-value';
$requestSign = \AKSRT\Auth\OAuthProxyProtocol::sign_request($request, $secret);
check(\AKSRT\Auth\OAuthProxyProtocol::verify_request($request, $secret, $requestSign), 'request signature failed');
check(!\AKSRT\Auth\OAuthProxyProtocol::verify_callback($request, $secret, $requestSign), 'direction-separated HMAC failed');
$tampered = $request; $tampered['provider'] = 'qq';
check(!\AKSRT\Auth\OAuthProxyProtocol::verify_request($tampered, $secret, $requestSign), 'tampered request accepted');
check(!\AKSRT\Auth\OAuthProxyProtocol::timestamp_valid((string) (time() - 1000)), 'expired timestamp accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('http://example.com') === '', 'HTTP URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://user@example.com') === '', 'userinfo URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://127.0.0.1') === '', 'loopback URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://10.0.0.1') === '', 'private URL accepted');
check(\AKSRT\Auth\OAuthProxyProtocol::callback_url('https://example.com/wrong') === '', 'wrong callback path accepted');
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
$base = $provider->get_defaults('users', 'oauth');
$base['oauth_s'] = true;
$base['oauth_weixingzh_s'] = true;
$base['weixingzh_gzh_type'] = 'service';
$base['weixingzh_appid'] = 'appid';
$base['weixingzh_appsecret'] = 'secret';
$base['weixingzh_token'] = '***';
$GLOBALS['wp_options']['aksrt_settings'] = [];
check(!$provider->validate('users', $base, 'oauth')['valid'], 'masked missing token treated as configured');
$GLOBALS['wp_options']['aksrt_settings'] = ['weixingzh_token' => 'stored-token'];
check($provider->validate('users', $base, 'oauth')['valid'], 'stored token not preserved by mask validation');
$provider->write_settings('users', ['weixingzh_token' => '***'], 'oauth');
check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'stored-token', 'masked token overwrote stored secret');
$provider->write_settings('users', ['weixingzh_token' => 'replacement'], 'oauth');
check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'replacement', 'new token failed to replace secret');
$provider->write_settings('users', ['__clear_secrets' => ['weixingzh_token']], 'oauth');
check(!isset($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token']), 'explicit secret clear failed');
$GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] = 'final-token';
$clearData = $base;
$clearData['weixingzh_token'] = '***';
$clearData['__clear_secrets'] = ['weixingzh_token'];
check(!$provider->validate('users', $clearData, 'oauth')['valid'], 'active provider allowed required secret clear');
check($provider->read_settings('users', 'oauth')['weixingzh_token'] === '***', 'secret read was not masked');
echo "OAuth security harness passed.\n";
}