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 absent from the OAuth surface', () => { for (const needle of [ 'oauth_baidu_s', 'oauth_alipay_s', 'get_baidu_user_data', 'get_alipay_user_data', 'OAuthLogin\\Baidu', 'OAuthLogin\\Alipay', "'baidu' => [", "'alipay' => [", ]) { assert.equal(oauthScope.includes(needle), false, `unexpected OAuth reference: ${needle}`) } }) 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') for (const provider of ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin']) { assert.match(catalog, new RegExp(`'${provider}'\\s*=>`)) } assert.match(catalog, /CLOGIN_TYPES = \['qq', 'weixin', 'weibo'\]/) 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, /aksrt_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 = { aksrtData: {}, 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) })