Files
2026-09-03 08:38:17 +08:00

95 lines
4.8 KiB
JavaScript

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);
});