95 lines
4.8 KiB
JavaScript
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);
|
|
});
|