import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import ts from 'typescript'; function loadOAuth({ api = {}, sdk = ['weixin', 'qq', 'google'], loginResult = {}, loginError } = {}) { const storage = new Map(); const navigation = []; const toasts = []; const opened = []; const loginOptions = []; const runtime = { arguments: '', openURL: (url) => opened.push(url) }; const uni = { getStorageSync: (key) => storage.get(key), setStorageSync: (key, value) => storage.set(key, value), removeStorageSync: (key) => storage.delete(key), getProvider: ({ success }) => success({ provider: sdk }), login: (options) => { loginOptions.push(options); loginError ? options.fail(loginError) : options.success(loginResult); }, reLaunch: ({ url }) => navigation.push(url), navigateTo: ({ url }) => navigation.push(url), showToast: ({ title }) => toasts.push(title), }; const source = readFileSync(new URL('../src/utils/oauth.ts', import.meta.url), 'utf8'); const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS } }).outputText; const exports = {}; // APP-PLUS functions return before their H5 branch, so this executes the real // native control flow with only the SDK and network boundary substituted. new Function('exports', 'require', 'uni', 'globalThis', js)(exports, () => ({ api }), uni, { plus: { runtime } }); return { ...exports, storage, navigation, toasts, opened, loginOptions, runtime }; } test('App buttons are the intersection of backend switches and packaged SDKs', async () => { const flow = loadOAuth({ sdk: ['weixin'], api: { oauthProviders: async (platform) => { assert.equal(platform, 'app'); return { items: [{ code: 'wechat' }, { code: 'qq' }, { code: 'github' }] }; } }, }); assert.deepEqual((await flow.availableOAuthProviders()).map((p) => p.code), ['wechat', 'github']); }); test('WeChat sends only its authorization code to the backend and enters the shared binding page', async () => { const flow = loadOAuth({ loginResult: { code: 'native-code' }, api: { oauthNative: async (...args) => { assert.deepEqual(args, ['wechat', 'native-code', '']); return { oauthCode: 'one-time-ticket' }; }, } }); await flow.beginOAuth({ code: 'wechat' }); assert.equal(flow.loginOptions[0].provider, 'weixin'); assert.equal(flow.loginOptions[0].onlyAuthorize, true); assert.match(flow.navigation[0], /oauth-callback\?oauthCode=one-time-ticket$/); }); test('QQ and Google exchange real SDK tokens, never client-supplied profile identities', async () => { for (const code of ['qq', 'google']) { const flow = loadOAuth({ loginResult: { authResult: { access_token: 'sdk-token', openid: 'untrusted-id' } }, api: { oauthNative: async (...args) => { assert.deepEqual(args, [code, '', 'sdk-token']); return { oauthCode: 'ticket' }; }, } }); await flow.beginOAuth({ code }); assert.equal(flow.navigation.length, 1); } }); test('SDK cancellation does not call the login API', async () => { const flow = loadOAuth({ loginError: { errMsg: 'login:fail cancel' } }); await assert.rejects(flow.beginOAuth({ code: 'qq' }), /已取消授权/); assert.equal(flow.navigation.length, 0); }); test('GitHub browser callback requires the pending request and preserves proof for exchange/binding', async () => { const flow = loadOAuth({ api: { oauthStart: async (code, platform) => { assert.equal(code, 'github'); assert.equal(platform, 'app'); return { authorizationUrl: 'https://github.com/login/oauth/authorize?state=request', appProof: 'private-proof', requestId: 'request', callbackUrl: 'xingyuim://oauth/callback' }; } } }); await flow.beginOAuth({ code: 'github' }); assert.equal(flow.opened.length, 1); flow.runtime.arguments = 'xingyuim://oauth/callback?requestId=attacker&oauthCode=stolen'; flow.handleAppOAuthReturn(); assert.equal(flow.navigation.length, 0); flow.runtime.arguments = 'xingyuim://oauth/callback?requestId=request&oauthCode=ticket'; assert.equal(flow.handleAppOAuthReturn(), true); assert.equal(flow.appOAuthProofFor('ticket'), 'private-proof'); assert.equal(flow.appOAuthProofFor('different-ticket'), ''); assert.equal(flow.handleAppOAuthReturn(), false); flow.clearAppOAuth(); assert.equal(flow.appOAuthProofFor('ticket'), ''); }); test('callback parser rejects foreign paths, duplicate fields and malformed encoding', () => { const flow = loadOAuth(); for (const raw of [ 'https://evil.example/oauth/callback?requestId=x', 'xingyuim://oauth/other?requestId=x', 'xingyuim://oauth/callback?requestId=x&requestId=y', 'xingyuim://oauth/callback?oauthCode=%ZZ', 'xingyuim://oauth/callback?requestId=x#fragment', ]) assert.equal(flow.parseAppOAuthCallback(raw), null); });