Files
wordpress-theme-aksrt/tests/widget-system/oauth-security-contract.test.cjs
T
kate522 b1c3d86e44 refactor(oauth): drop the hand-written providers and keep only what the SDK ships
The previous commit added five direct clients (Douyin, Microsoft, Facebook,
Twitter, Apple) written against each platform's own endpoints. Maintaining
five bespoke OAuth implementations — including Apple's ES256 client secret and
Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for,
and none of them could be verified without production credentials.

Only providers yurunsoft/yurun-oauth-login actually implements stay on the
direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay.
Baidu and Alipay move into OAuthProviderBroker's existing client() switch and
normalize() map rather than living in a separate class, so there is no new
layer to reason about.

OAuthProviderClient, its harness and its contract test are deleted outright
(-809 lines). The Apple form_post handling added to handle_callback() is
reverted too — without Apple there is no POST callback to read.

The aggregate-login channel keeps its 16 platforms, matching the reference;
that list describes what the aggregation provider offers, not a local
implementation. Brand icons for those names stay for the same reason.

Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0
errors, and the catalog constant, PHP schema and Vue schema now list the same
16 aggregate platforms.
2026-11-04 18:33:37 +08:00

223 lines
9.4 KiB
JavaScript

const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const vm = require('node:vm')
const { execFileSync } = require('node:child_process')
const root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const walkFiles = (dir, extensions) => {
const result = []
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
const relative = path.join(dir, entry.name)
if (entry.isDirectory()) result.push(...walkFiles(relative, extensions))
else if (extensions.some((ext) => entry.name.endsWith(ext))) result.push(relative)
}
return result
}
const oauthScope = [
'src/Auth/OAuth.php',
'src/Auth/OAuthProviderCatalog.php',
'src/Auth/OAuthProviderBroker.php',
'src/Auth/OAuthAccountService.php',
'src/Auth/OAuthProxyClient.php',
'src/Auth/OAuthProxyServer.php',
'src/Auth/CLoginClient.php',
'src/Admin/Schema/SchemaUsers.php',
'assets/admin/src/schema/sections/users/oauth.js',
].map(read).join('\n')
test('OAuth PHP behavior harness passes', () => {
const output = execFileSync('php', [path.join(root, 'tests/widget-system/oauth-security-harness.php')], { encoding: 'utf8' })
assert.match(output, /OAuth security harness passed/)
})
test('Baidu and Alipay are wired through the bundled SDK', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
const broker = read('src/Auth/OAuthProviderBroker.php')
const schema = read('src/Admin/Schema/SchemaUsers.php')
for (const provider of ['baidu', 'alipay']) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
assert.match(schema, new RegExp(`oauth_${provider}_s`), provider)
// 两个平台都走 yurun SDK,而不是自建端点。
assert.match(broker, new RegExp(`Yurun\\\\OAuthLogin\\\\${provider[0].toUpperCase()}${provider.slice(1)}\\\\OAuth2`), provider)
}
// 支付宝的支付网关仍在,两者互不影响。
assert.match(read('src/Payment/Gateways/AlipayGateway.php'), /class AlipayGateway/)
})
test('未提供自建客户端的平台不进入 OAuth 表面', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
const schema = read('src/Admin/Schema/SchemaUsers.php')
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
// 抖音 / Microsoft / Facebook / Twitter / Apple 只存在于聚合渠道,不再是直连平台。
for (const provider of ['douyin', 'microsoft', 'facebook', 'twitter', 'apple']) {
assert.doesNotMatch(catalog, new RegExp(`'${provider}'\\s*=>\\s*\\[`), provider)
assert.doesNotMatch(schema, new RegExp(`oauth_${provider}_s`), provider)
assert.doesNotMatch(jsSchema, new RegExp(`oauth_${provider}_s`), provider)
}
assert.equal(fs.existsSync(path.join(root, 'src/Auth/OAuthProviderClient.php')), false)
})
test('Alipay payment remains available', () => {
const functions = read('functions.php')
const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
const gateway = read('src/Payment/Gateways/AlipayGateway.php')
assert.match(functions, /AlipayGateway/)
assert.match(paymentSchema, /alipay_appid/)
assert.match(paymentSchema, /alipay_privatekey/)
assert.match(gateway, /class AlipayGateway/)
})
test('OAuth uses one-time transactions and no PHP sessions', () => {
const auth = fs.readdirSync(path.join(root, 'src', 'Auth'))
.filter((name) => name.endsWith('.php'))
.map((name) => read(path.join('src', 'Auth', name)))
.join('\n')
assert.match(auth, /OAuthTransactionStore::issue/)
assert.match(auth, /OAuthTransactionStore::claim/)
assert.equal(auth.includes('session_start('), false)
assert.equal(auth.includes('$_SESSION'), false)
})
test('provider catalog is the canonical provider list', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
const direct = ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay']
for (const provider of direct) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
}
// 聚合登录平台与参考主题一致(16 种)。
const block = catalog.slice(catalog.indexOf('CLOGIN_TYPES = ['), catalog.indexOf('];', catalog.indexOf('CLOGIN_TYPES = [')))
const types = [...block.matchAll(/'([a-z]+)'/g)].map((m) => m[1])
assert.equal(types.length, 16, types.join(','))
for (const type of ['dingtalk', 'huawei', 'xiaomi', 'alipay', 'douyin', 'apple', 'microsoft']) {
assert.ok(types.includes(type), type)
}
assert.match(catalog, /oauth_s/)
assert.match(catalog, /oauth_bind_type/)
})
test('verified email is required for email auto-linking', () => {
const account = read('src/Auth/OAuthAccountService.php')
const broker = read('src/Auth/OAuthProviderBroker.php')
assert.match(account, /!empty\(\$identity\['email_verified'\]\)/)
assert.match(broker, /primary.*verified/s)
assert.match(broker, /email_verified.*Google|Google.*email_verified/s)
})
test('OAuth secrets are marked and masked by the schema provider', () => {
const phpSchema = read('src/Admin/Schema/SchemaUsers.php')
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
const provider = read('src/Admin/SchemaProvider.php')
for (const key of [
'oauth_proxy_secret',
'github_appkey',
'gitee_appkey',
'qq_appkey',
'google_client_secret',
'weibo_appkey',
'weixin_appsecret',
'weixingzh_token',
'weixingzh_appsecret',
'clogin_key',
]) {
assert.match(phpSchema, new RegExp(`${key}[^\\n]+secret`))
assert.match(jsSchema, new RegExp(`${key}[^\\n]+secret`))
}
assert.match(provider, /\*\*\*/)
assert.match(provider, /!empty\(\$field\['secret'\]\)/)
})
test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
const php = read('src/Admin/Schema/SchemaUsers.php')
const js = read('assets/admin/src/schema/sections/users/oauth.js')
const start = php.indexOf("'oauth' => [")
const phpSection = php.slice(start, php.indexOf("'sms' => [", start))
const phpKeys = [...phpSection.matchAll(/\['key' => '([^']+)', 'label'/g)].map((match) => match[1])
const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
})
test('OAuth bind starts require a one-time captcha bind pass', () => {
const oauth = read('src/Auth/OAuth.php')
const weixingzh = read('src/Auth/WeixinGzh.php')
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php')
assert.match(oauth, /\/oauth\/bind-pass/)
assert.match(oauth, /BIND_PASS_KIND = 'oauth_bind_pass'/)
assert.match(oauth, /OAuthTransactionStore::issue\(self::BIND_PASS_KIND/)
assert.match(oauth, /OAuthTransactionStore::claim\(\$token, self::BIND_PASS_KIND, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, \$provider, \$user_id, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, 'clogin_' \. \$type, get_current_user_id\(\), true\)/)
assert.match(weixingzh, /OAuth::validate_bind_pass\(\$bind_pass, 'weixingzh'/)
assert.match(bindings, /jio_render_captcha\('bind'\)/)
})
test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
const notices = []
let replaced = ''
const listeners = {}
const location = new URL('https://site.test/sign/?oauth_action=error&oauth_provider=github&oauth_error=%3Cscript%3E&keep=1#section')
const documentElement = { dataset: {}, contains: () => true }
const document = {
readyState: 'complete',
documentElement,
visibilityState: 'visible',
querySelectorAll: () => [],
querySelector: () => null,
addEventListener: (name, fn) => { listeners[name] = fn },
activeElement: null,
body: { classList: { add() {}, remove() {} } },
}
let authInit = null
const jQueryApi = {
off() { return jQueryApi },
on(_name, fn) { authInit = fn; return jQueryApi },
}
const jQueryStub = () => jQueryApi
const window = {
jioData: {},
URL,
location,
history: { replaceState: (_state, _title, value) => { replaced = value } },
notyf: (message, type) => notices.push({ message, type }),
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
setInterval,
clearInterval,
localStorage: { getItem: () => null, setItem() {} },
}
vm.runInNewContext(read('assets/js/auth.js'), {
window,
document,
URL,
FormData: class {},
fetch: () => Promise.reject(new Error('unexpected fetch')),
console,
CustomEvent: class {},
AbortController,
Date,
Math,
Promise,
jQuery: jQueryStub,
})
if (authInit) authInit()
assert.deepEqual(notices, [{ message: '第三方授权失败,请重试', type: 'danger' }])
assert.equal(replaced, '/sign/?keep=1#section')
assert.equal(replaced.includes('script'), false)
})
test('obsolete OAuth admin routes and components are removed', () => {
const controller = read('src/Admin/UserController.php')
const usersApi = read('assets/admin/src/api/modules/users.js')
assert.equal(controller.includes("'/settings/oauth'"), false)
assert.equal(controller.includes("'/settings/oauth-proxy'"), false)
assert.equal(usersApi.includes('getOAuthSettings'), false)
assert.equal(usersApi.includes('saveOAuthSettings'), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/components/OAuthProviderForm.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersSectionPage.vue')), false)
})