Files
wordpress-theme-aksrt/tests/widget-system/auth-source-contract.test.cjs
T
kate522 0bee2c605e refactor(auth): stop pre-escaping the OAuth provider URLs
get_auth_providers_data() returned esc_url()-ed URLs while all four of its
consumers (the modal, the user card, render_social_buttons and the comment
form) escaped again on output. Its sibling get_frontend_bindings_data() stores
the raw URL and lets the binding list escape it, so the class was inconsistent
with itself.

I first reported this as a double-escaping bug that broke ampersand-bearing
URLs. That was wrong: I had verified it with a stub that modelled esc_url() as
htmlspecialchars(). The real esc_url() runs wp_kses_normalize_entities()
first, which explicitly decodes double-encoded character references, so
esc_url() is idempotent on its own output. Re-checked against the WordPress
source with a faithful transcription, and re-ran the popover markup: the
WeChat href is ?tab=oauth&oauth_intent=login, one entity, unchanged.

So the escaping was redundant rather than broken. The producer now hands out
raw URLs and each surface escapes exactly once, which is where WordPress
expects it and matches get_frontend_bindings_data(). Rendered output is
byte-identical.

Extended the auth source contract to pin the invariant: the producer stays
raw and all four surfaces escape on output.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and visual (614 tests), plus php -l.
2026-10-17 14:54:10 +08:00

316 lines
17 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('modal and sign page use one renderer and one state machine', () => {
const modal = read('templates/parts/modal-auth.php');
const page = read('templates/pages/page-sign.php');
const shared = read('templates/parts/auth-content.php');
const scripts = read('src/Core/ScriptsPart1c.php');
const runtime = read('assets/js/runtime/main.js') + read('assets/js/runtime/main.min.js');
const loader = read('assets/js/runtime/loader.js');
assert.match(modal, /templates\/parts\/auth-content\.php/);
assert.match(page, /templates\/parts\/auth-content\.php/);
assert.match(shared, /data-auth-panel="signin"/);
assert.match(shared, /data-auth-panel="signup"/);
assert.match(shared, /data-auth-panel="resetpassword"/);
assert.match(shared, /data-auth-panel="bind"/);
assert.match(shared, /data-auth-panel="oauth"/);
assert.match(scripts, /assets\/js\/auth\.js/);
assert.match(scripts, /'aksrt-auth',[\s\S]*?assets\/js\/auth\.js[\s\S]*?\['loader_js'\]/);
assert.doesNotMatch(scripts, /modal-auth\.js|sign-register\.js/);
assert.doesNotMatch(runtime, /signsubmit-loader|tbquire\(\['sign-register'\]\)|\.on\('click', '\.(?:signin|signup)-loader'/);
assert.doesNotMatch(loader, /sign-register/);
});
test('static Zibll authentication dimensions and responsive contract are retained', () => {
const css = read('assets/css/routes/sign.css');
assert.match(css, /\.sign\s*\{[\s\S]*?max-width:\s*330px/);
assert.match(css, /#u_sign\.has-sign-img \.modal-dialog\s*\{\s*width:\s*800px/);
assert.match(css, /#u_sign \.sign-img\s*\{[^}]*inset:\s*0 45% 0 0;[^}]*padding:\s*0/);
assert.match(css, /#u_sign \.sign-img \+ \.sign\s*\{[^}]*margin-left:\s*55%/);
assert.match(css, /\.social-login-item\s*\{[\s\S]*?width:\s*32px;[\s\S]*?height:\s*32px/);
assert.match(css, /\.auth-qrcode\s*\{[\s\S]*?width:\s*180px;[\s\S]*?min-height:\s*180px/);
assert.match(css, /@media \(max-width:\s*767px\)[\s\S]*?\.sign-img\s*\{\s*display:\s*none/);
});
test('authentication settings use reference defaults and canonical keys', () => {
const js = read('assets/admin/src/schema/sections/users/auth.js');
const php = read('src/Admin/Schema/SchemaUsers.php');
const all = js + '\n' + php;
assert.match(all, /user_sign_type['"]?\s*(?:=>|:)\s*['"]modal/);
assert.match(all, /user_signup_captch['"]?\s*(?:=>|:)\s*false/);
assert.match(all, /sign_card_position['"]?\s*(?:=>|:)\s*['"]right/);
assert.match(all, /sign_show_logo['"]?\s*(?:=>|:)\s*false/);
assert.match(all, /sign_modal_show_logo['"]?\s*(?:=>|:)\s*false/);
assert.match(all, /invit_code_s/);
assert.match(all, /only_nopas/);
assert.match(all, /user_signup_no_repas[\s\S]*?dependsOn[\s\S]*?user_signup_captch/);
assert.doesNotMatch(all, /user_close_login|user_close_signup|nopas_first|password_first|nopas_only|referral_registration_enabled|referral_register_field/);
});
test('authentication galleries save attachment IDs and are consumed by both surfaces', () => {
const field = read('assets/admin/src/components/form/AksField.vue');
const gallery = read('assets/admin/src/components/form/AksGalleryField.vue');
const provider = read('src/Admin/SchemaProvider.php');
const reader = read('src/Settings/AuthReader.php');
const modal = read('templates/parts/modal-auth.php');
const page = read('templates/pages/page-sign.php');
const css = read('assets/css/routes/sign.css');
assert.match(field, /<AksGalleryField[\s\S]*?schema\.type === 'gallery'/);
assert.match(gallery, /multiple:\s*true/);
assert.match(gallery, /Number\.parseInt\(attachment\.id/);
assert.match(provider, /'gallery'\s*=>\s*'attachment_ids'/);
assert.match(provider, /case 'attachment_ids':[\s\S]*?array_map\('absint'/);
assert.match(reader, /auth_gallery_ids\('sign_modal_background'\)/);
assert.match(reader, /auth_gallery_ids\('sign_page_background'\)/);
assert.match(modal, /wp_get_attachment_image\(\$image_id, 'full'/);
assert.match(modal, /<div class="sign-img"><\?php echo \$image_html/);
assert.match(page, /wp_get_attachment_image_url\(\$background_id, 'full'\)/);
assert.match(page, /<div class="sign-page-background" style="background-image:url/);
assert.match(css, /\.sign-img img\s*\{[\s\S]*?object-fit:\s*cover/);
assert.doesNotMatch(modal + page, /is_string\(\$images\)|explode\(',', \$images\)/);
});
test('only the shared authentication REST routes and /sign/ renderer are registered', () => {
const controller = read('src/Admin/UserController.php');
const router = read('src/Auth/Router.php');
const urls = read('inc/modules/user-parts/urls.php');
['/auth/code', '/auth/login', '/auth/register', '/auth/reset-password'].forEach((route) => {
assert.match(controller, new RegExp(route.replace('/', '\\/')));
});
assert.doesNotMatch(controller, /auth\/(?:modal-|phone-|send-sms-code|bind-phone)/);
assert.match(router, /'sign'\s*=>\s*'index\.php\?aksrt_sign=1'/);
assert.doesNotMatch(router, /'login'\s*=>|'register'\s*=>|'reset-password'\s*=>|'verify-email'\s*=>|'bind-account'\s*=>/);
assert.match(router, /aksrt_bypass/);
assert.match(router, /\$action === 'logout'[\s\S]*?aksrt_get_logout_url/);
assert.doesNotMatch(router, /is_user_logged_in\(\) && current_user_can\('manage_options'\)|wp_doing_ajax\(\)/);
assert.match(urls, /home_url\('\/sign\/'\)/);
});
test('legacy authentication templates and scripts are removed', () => {
[
'assets/js/modal-auth.js',
'assets/js/sign-register.js',
'assets/js/runtime/sign-register.js',
'assets/js/runtime/sign-register.min.js',
'assets/css/routes/auth.css',
'assets/css/frontend/enhancements/modal-motion.css',
'templates/pages/page-login.php',
'templates/pages/page-register.php',
'templates/pages/page-reset-password.php',
'templates/pages/page-bind-account.php',
'templates/pages/page-verify-email.php',
].forEach((file) => assert.equal(fs.existsSync(path.join(root, file)), false, file));
});
test('unified handlers enforce nonce, captcha, rate limits, configured channels, and generic errors', () => {
const handlers = read('src/Admin/UserControllerAuthPhoneOperations.php');
const service = read('src/Auth/Service.php');
const serviceA = read('src/Auth/ServicePart1a.php');
const serviceB = read('src/Auth/ServicePart1b.php');
assert.match(handlers, /auth_verify_rest_nonce/);
assert.match(handlers, /private function auth_require_captcha/);
assert.match(handlers, /get_error_code\(\)/);
assert.match(handlers, /get_error_message\(\)/);
assert.match(handlers, /auth_rate_limited/);
assert.match(handlers, /auth_channel_allowed/);
['auth_code', 'auth_login', 'auth_register', 'auth_reset_password'].forEach((method) => {
const start = handlers.indexOf(`public function ${method}(`);
const end = handlers.indexOf('\n\tpublic function ', start + 1);
const body = handlers.slice(start, end === -1 ? handlers.length : end);
assert.match(body, /auth_require_captcha/, method);
});
assert.match(handlers, /passwordless_login_type/);
assert.match(handlers, /signup_verification_type/);
assert.match(handlers, /reset_password_verification_type/);
assert.match(handlers, /账号或密码错误/);
assert.match(handlers, /账号或验证码错误/);
assert.match(service, /signin_phone_enabled/);
assert.doesNotMatch(service + serviceA + serviceB, /aksrt_require_captcha/);
assert.doesNotMatch(service + serviceA + serviceB, /_auth_code_verified|_defer_login/);
assert.doesNotMatch(service, /captcha_scene/);
assert.match(handlers, /public function user_bind_phone[\s\S]*?auth_require_captcha\('bind_phone'/);
assert.doesNotMatch(service, /login_by_phone_password|login_by_phone_code/);
});
test('OAuth bind and reset flows stay inside the shared page and four auth endpoints', () => {
const shared = read('templates/parts/auth-content.php');
const page = read('templates/pages/page-sign.php');
const handlers = read('src/Admin/UserControllerAuthPhoneOperations.php');
const serviceA = read('src/Auth/ServicePart1a.php');
const serviceB = read('src/Auth/ServicePart1b.php');
assert.match(page, /'obk'\s*=>\s*\$obk/);
assert.match(shared, /data-auth-panel="bind"[\s\S]*?data-auth-form="login"/);
assert.match(shared, /name="obk"/);
assert.match(handlers, /finalize_oauth_binding/);
assert.match(serviceA, /finalize_oauth_binding/);
assert.doesNotMatch(serviceA + serviceB, /bind_oauth_account|get_reset_context|request_password_reset|reset_password_with_token/);
});
test('shared renderer contracts every configured authentication state', () => {
const shared = read('templates/parts/auth-content.php');
const modal = read('templates/parts/modal-auth.php');
const client = read('assets/js/auth.js');
assert.match(shared, /data-auth-password-fields/);
assert.match(shared, /data-auth-code-fields/);
assert.match(shared, /data-login-mode/);
['login', 'register', 'resetpassword'].forEach((scene) => {
assert.match(shared, new RegExp(`data-send-code="${scene}"`));
});
assert.match(shared, /\$omit_confirm/);
assert.match(shared, /\$ref_mode !== 'hidden'/);
assert.match(shared, /\$ref_mode === 'required'/);
assert.match(shared, /sign-agreement-implicit/);
assert.match(shared, /name="agree_terms"[\s\S]*?required/);
assert.match(shared, /oauth-button-lg/);
assert.match(shared, /data-auth-qrcode/);
assert.match(modal, /data-auto=/);
assert.match(client, /aksrtAuthAutoUntil/);
assert.match(client, /data-password-show/);
assert.match(client, /pollQr/);
assert.match(client, /AbortController/);
assert.match(client, /refreshInFlight|inFlight/);
assert.match(client, /visibilitychange/);
assert.match(client, /Math\.pow\(2/);
assert.match(client, /QR_TIMEOUT/);
assert.match(client, /stopQr\(root\)/);
assert.match(client, /window\.notyf\(escapeHtml\(text\), type \|\| 'info'\)/);
assert.match(client, /notify\('请先完成人机验证', 'warning'\)/);
assert.match(client, /form\.noValidate = true/);
assert.match(client, /form\.checkValidity\(\)[\s\S]*?field\.validationMessage/);
assert.match(client, /window\.location\.assign\(authPageUrl/);
assert.doesNotMatch(client + shared, /showMessage|data-auth-message|sign-message/);
assert.match(read('src/Settings/CreditsRuntime.php'), /invit_code_s/);
assert.match(read('src/Settings/CreditsRuntime.php'), /\['close', 'open', 'must'\]/);
});
test('OAuth frontend is accessible, redirect-aware, and removes retired providers', () => {
const shared = read('templates/parts/auth-content.php');
const modal = read('templates/parts/modal-auth.php');
const client = read('assets/js/auth.js');
const userCenter = read('assets/js/routes/user-center.js');
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php');
const signCss = read('assets/css/routes/sign.css');
const mainCss = read('assets/css/main.css');
assert.match(modal, /role="dialog"[^>]*aria-modal="true"[^>]*aria-labelledby="aksrt-auth-modal-title"/);
assert.match(modal, /id="aksrt-auth-modal-title"/);
assert.match(client, /focusableModalItems/);
assert.match(client, /lastModalTrigger/);
assert.match(client, /restore\.focus\(\)/);
assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="signin"/);
assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="oauth"/);
assert.match(client, /setAttribute\('role', 'tab'\)/);
assert.match(client, /aria-controls/);
assert.match(client, /aria-selected/);
assert.match(shared, /data-password-show[^>]*aria-label=/);
assert.match(shared, /aria-pressed="false"/);
assert.match(client, /setAttribute\('aria-label', visible \? '隐藏密码' : '显示密码'\)/);
assert.match(client, /setAttribute\('aria-pressed', visible \? 'true' : 'false'\)/);
assert.match(shared, /data-auth-qr-status[^>]*role="status"[^>]*aria-live="polite"/);
assert.match(shared, /data-provider=/);
assert.match(shared, /aria-label="<\?php echo esc_attr\(sprintf/);
assert.match(client, /searchParams\.set\('redirect_to', redirect\)/);
assert.match(client, /oauth_action/);
assert.match(client, /oauth_provider/);
assert.match(client, /oauth_error/);
assert.match(client, /oauthErrorMessages/);
assert.doesNotMatch(client, /notify\([^\n;]*searchParams\.get\('oauth_error'/);
assert.match(userCenter, /errorMessages\[currentUrl\.searchParams\.get\('oauth_error'\)/);
assert.match(userCenter, /result\.status === 409/);
assert.match(userCenter, /d\.message \|\| \(d\.data && d\.data\.message\)/);
assert.match(bindings, /\$binding\['provider'\] \?\? \$binding\['key'\]/);
assert.match(bindings, /\$binding\['start_url'\] \?\? \$binding\['url'\]/);
assert.match(bindings, /data-provider=/);
assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i);
assert.match(mainCss, /\.pay-box \.but\.baidu/);
});
test('the social-login area drops the implicit agreement line', () => {
const shared = read('templates/parts/auth-content.php');
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
// The reference shows no agreement copy under the social buttons; only the
// registration panel keeps its own, and it stays reachable.
assert.doesNotMatch(shared, /使用社交账号登录即表示/);
assert.match(shared, /注册即表示/);
assert.match(shared, /name="agree_terms"[\s\S]*?required/);
assert.doesNotMatch(userRenderer, /使用社交账号登录即表示|我已阅读并同意/);
});
test('brand icons resolve Gitee and Google to the reference SVG sprite', () => {
const icons = read('inc/helpers/aksrt-icons.php');
const sprites = read('assets/js/runtime/svg-icon.js');
// FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so both
// resolve to the theme's own symbol sheet exactly like the reference does.
assert.match(icons, /'gitee'\s*=>\s*'zibsvg-gitee'/);
assert.match(icons, /'google'\s*=>\s*'zibsvg-google-color'/);
assert.match(icons, /'github'\s*=>\s*'fa fab fa-github'/);
assert.match(icons, /'wechat'\s*=>\s*'fa fab fa-weixin'/);
assert.match(sprites, /aksrt_svgs\['gitee'\]/);
assert.match(sprites, /aksrt_svgs\['google-color'\]/);
// Sprite values must reach the shared icon renderer instead of being wrapped
// in a bogus <i class="zibsvg-…">.
assert.match(icons, /strpos\(\$class, 'zibsvg-'\) === 0[\s\S]*?aksrt_icon_markup\(\$class, \$extra_classes\)/);
});
test('the top-nav user popover reproduces the reference quick-login block', () => {
const oauth = read('src/Auth/OAuth.php');
const header = read('inc/helpers/header/aksrt-header-navbar.php');
const links = read('inc/helpers/aksrt-links.php');
const legacyCss = read('assets/css/frontend/pages/base-1.css');
assert.match(oauth, /social-separator separator muted-3-color em09 mt10/);
assert.match(oauth, /'<div class="social_loginbar">'/);
assert.match(oauth, /'social-login-item ' \. \$type/);
assert.match(oauth, /' button-lg' : ' toggle-radius'/);
assert.match(oauth, /rel="nofollow"/);
assert.match(oauth, /qrcode-signin/);
assert.match(oauth, /oauth_button_lg/);
// Callers append the block verbatim — no hand-rolled separator or wrapper.
assert.match(header, /render_social_buttons\(__\('快速登录', 'aksrt'\)\)/);
assert.doesNotMatch(header, /social-separator|social_loginbar/);
assert.match(links, /render_social_buttons\(__\('使用以下方式快速登录', 'aksrt'\)\)/);
assert.doesNotMatch(links, /social_loginbar/);
// The retired .social-login-divider / .oauth-row / .oauth-btn markup and its
// stylesheet are gone.
assert.doesNotMatch(oauth, /oauth-row|oauth-btn|social-login-divider/);
assert.doesNotMatch(legacyCss, /\.oauth-row|\.oauth-btn|\.social-login-divider/);
});
test('provider URLs stay raw in the data layer and are escaped once per surface', () => {
const oauth = read('src/Auth/OAuth.php');
const shared = read('templates/parts/auth-content.php');
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
const comments = read('inc/helpers/aksrt-comments.php');
// get_auth_providers_data() hands out raw URLs, exactly like its sibling
// get_frontend_bindings_data() does. esc_url() is idempotent on its own
// output, so escaping here as well was redundant rather than broken.
assert.doesNotMatch(oauth, /'url'\s*=>\s*esc_url\(\$url\)/);
assert.match(oauth, /'url'\s*=>\s*\$url,/);
// Every consumer still escapes on output, so the rendered href is unchanged.
assert.match(shared, /esc_url\(\(string\) \(\$provider\['url'\] \?\? '#'\)\)/);
assert.match(userRenderer, /href="' \. esc_url\(\$url\)/);
assert.match(oauth, /'<a rel="nofollow" href="' \. esc_url\(\$url\)/);
assert.match(comments, /<a href="<\?php echo esc_url\(\$url\); \?\>" class="but hollow social-login-item ml10">/);
});